1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-11-27 08:21:03 +02:00

All: Security: Added way to upgrade master key encryption and sync target encryption

This commit is contained in:
Laurent Cozic 2020-03-13 17:42:50 +00:00
parent 3917e3469d
commit f4958de885
17 changed files with 423 additions and 164 deletions

View File

@ -23,7 +23,7 @@ jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; // The first test is slow because the
let service = null;
describe('Encryption', function() {
describe('services_EncryptionService', function() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
@ -49,7 +49,6 @@ describe('Encryption', function() {
it('should generate and decrypt a master key', asyncTest(async () => {
const masterKey = await service.generateMasterKey('123456');
expect(!!masterKey.checksum).toBe(true);
expect(!!masterKey.content).toBe(true);
let hasThrown = false;
@ -65,6 +64,91 @@ describe('Encryption', function() {
expect(decryptedMasterKey.length).toBe(512);
}));
it('should upgrade a master key', asyncTest(async () => {
// Create an old style master key
let masterKey = await service.generateMasterKey('123456', {
encryptionMethod: EncryptionService.METHOD_SJCL_2,
});
masterKey = await MasterKey.save(masterKey);
let upgradedMasterKey = await service.upgradeMasterKey(masterKey, '123456');
upgradedMasterKey = await MasterKey.save(upgradedMasterKey);
// Check that master key has been upgraded (different ciphertext)
expect(masterKey.content).not.toBe(upgradedMasterKey.content);
// Check that master key plain text is still the same
const plainTextOld = await service.decryptMasterKey_(masterKey, '123456');
const plainTextNew = await service.decryptMasterKey_(upgradedMasterKey, '123456');
expect(plainTextOld.content).toBe(plainTextNew.content);
// Check that old content can be decrypted with new master key
await service.loadMasterKey_(masterKey, '123456', true);
const cipherText = await service.encryptString('some secret');
const plainTextFromOld = await service.decryptString(cipherText);
await service.loadMasterKey_(upgradedMasterKey, '123456', true);
const plainTextFromNew = await service.decryptString(cipherText);
expect(plainTextFromOld).toBe(plainTextFromNew);
}));
it('should not upgrade master key if invalid password', asyncTest(async () => {
let masterKey = await service.generateMasterKey('123456', {
encryptionMethod: EncryptionService.METHOD_SJCL_2,
});
const hasThrown = await checkThrowAsync(async () => await service.upgradeMasterKey(masterKey, '777'));
}));
it('should require a checksum only for old master keys', asyncTest(async () => {
const masterKey = await service.generateMasterKey('123456', {
encryptionMethod: EncryptionService.METHOD_SJCL_2,
});
expect(!!masterKey.checksum).toBe(true);
expect(!!masterKey.content).toBe(true);
}));
it('should not require a checksum for new master keys', asyncTest(async () => {
const masterKey = await service.generateMasterKey('123456', {
encryptionMethod: EncryptionService.METHOD_SJCL_4,
});
expect(!masterKey.checksum).toBe(true);
expect(!!masterKey.content).toBe(true);
const decryptedMasterKey = await service.decryptMasterKey_(masterKey, '123456');
expect(decryptedMasterKey.length).toBe(512);
}));
it('should throw an error if master key decryption fails', asyncTest(async () => {
const masterKey = await service.generateMasterKey('123456', {
encryptionMethod: EncryptionService.METHOD_SJCL_4,
});
const hasThrown = await checkThrowAsync(async () => await service.decryptMasterKey_(masterKey, 'wrong'));
expect(hasThrown).toBe(true);
}));
it('should return the master keys that need an upgrade', asyncTest(async () => {
const masterKey1 = await MasterKey.save(await service.generateMasterKey('123456', {
encryptionMethod: EncryptionService.METHOD_SJCL_2,
}));
const masterKey2 = await MasterKey.save(await service.generateMasterKey('123456', {
encryptionMethod: EncryptionService.METHOD_SJCL,
}));
const masterKey3 = await MasterKey.save(await service.generateMasterKey('123456'));
const needUpgrade = service.masterKeysThatNeedUpgrading(await MasterKey.all());
expect(needUpgrade.length).toBe(2);
expect(needUpgrade.map(k => k.id).sort()).toEqual([masterKey1.id, masterKey2.id].sort());
}));
it('should encrypt and decrypt with a master key', asyncTest(async () => {
let masterKey = await service.generateMasterKey('123456');
masterKey = await MasterKey.save(masterKey);

View File

@ -973,7 +973,6 @@ class Application extends BaseApplication {
});
},
}, {
id: 'view:toggleLayout',
label: _('Toggle editor layout'),
screens: ['Main'],
accelerator: 'CommandOrControl+L',
@ -1196,8 +1195,6 @@ class Application extends BaseApplication {
menuItem.enabled = !isHtmlNote && layout !== 'viewer' && !!note;
}
const toggleLayout = Menu.getApplicationMenu().getMenuItemById('view:toggleLayout');
toggleLayout.enabled = !!note;
const menuItem = Menu.getApplicationMenu().getMenuItemById('help:toggleDevTools');
menuItem.checked = state.devToolsVisible;
}

View File

@ -5,6 +5,7 @@ const EncryptionService = require('lib/services/EncryptionService');
const { themeStyle } = require('../theme.js');
const { _ } = require('lib/locale.js');
const { time } = require('lib/time-utils.js');
const { shim } = require('lib/shim');
const dialogs = require('./dialogs');
const shared = require('lib/components/shared/encryption-config-shared.js');
const { bridge } = require('electron').remote.require('./bridge');
@ -15,28 +16,18 @@ class EncryptionConfigScreenComponent extends React.Component {
shared.constructor(this);
}
componentDidMount() {
this.isMounted_ = true;
}
componentWillUnmount() {
this.isMounted_ = false;
shared.componentWillUnmount();
}
initState(props) {
return shared.initState(this, props);
componentDidMount() {
this.isMounted_ = true;
shared.componentDidMount(this);
}
async refreshStats() {
return shared.refreshStats(this);
}
UNSAFE_componentWillMount() {
this.initState(this.props);
}
UNSAFE_componentWillReceiveProps(nextProps) {
this.initState(nextProps);
componentDidUpdate(prevProps) {
shared.componentDidUpdate(this, prevProps);
}
async checkPasswords() {
@ -61,7 +52,7 @@ class EncryptionConfigScreenComponent extends React.Component {
return shared.onPasswordChange(this, mk, event.target.value);
};
const password = this.state.passwords[mk.id] ? this.state.passwords[mk.id] : '';
const password = this.props.passwords[mk.id] ? this.props.passwords[mk.id] : '';
const active = this.props.activeMasterKeyId === mk.id ? '✔' : '';
const passwordOk = this.state.passwordChecks[mk.id] === true ? '✔' : '❌';
@ -83,9 +74,73 @@ class EncryptionConfigScreenComponent extends React.Component {
);
}
renderNeedUpgradeSection() {
if (!shim.isElectron()) return null;
const needUpgradeMasterKeys = EncryptionService.instance().masterKeysThatNeedUpgrading(this.props.masterKeys);
if (!needUpgradeMasterKeys.length) return null;
const theme = themeStyle(this.props.theme);
const rows = [];
const comp = this;
for (const mk of needUpgradeMasterKeys) {
rows.push(
<tr key={mk.id}>
<td style={theme.textStyle}>{mk.id}</td>
<td><button onClick={() => shared.upgradeMasterKey(comp, mk)} style={theme.buttonStyle}>Upgrade</button></td>
</tr>
);
}
return (
<div>
<h1 style={theme.h1Style}>{_('Master keys that need upgrading')}</h1>
<p style={theme.textStyle}>{_('The following master keys use an out-dated encryption algorithm and it is recommended to upgrade them. The upgraded master key will still be able to decrypt and encrypt your data as usual.')}</p>
<table>
<tbody>
<tr>
<th style={theme.textStyle}>{_('ID')}</th>
<th style={theme.textStyle}>{_('Upgrade')}</th>
</tr>
{rows}
</tbody>
</table>
</div>
);
}
renderReencryptData() {
if (!shim.isElectron()) return null;
const theme = themeStyle(this.props.theme);
const buttonLabel = _('Reencrypt data');
const intro = this.props.shouldReencrypt ? _('The default encryption method has been changed to a more secure one and it is recommended that you apply it to your data.') : _('You may use the tool below to reencrypt your data, for example if you know that some of your notes are encrypted with an obsolete encryption method.');
let t = `${intro}\n\n${_('In order to do so, your entire data set will have to encrypted and synchronised, so it is best to run it overnight.\n\nTo start, please follow these instructions:\n\n1. Synchronise all your devices.\n2. Click "%s".\n3. Let it run to completion. While it runs, avoid changing any note on your other devices, to avoid conflicts.\n4. Once sync is done on this device, sync all your other devices and let it run to completion.\n\nImportant: you only need to run this ONCE on one device.', buttonLabel)}`;
t = t.replace(/\n\n/g, '</p><p>');
t = t.replace(/\n/g, '<br>');
t = `<p>${t}</p>`;
return (
<div>
<h1 style={theme.h1Style}>{_('Reencryption')}</h1>
<p style={theme.textStyle} dangerouslySetInnerHTML={{ __html: t }}></p>
<span style={{ marginRight: 10 }}>
<button onClick={() => shared.reencryptData()} style={theme.buttonStyle}>{buttonLabel}</button>
</span>
{ !this.props.shouldReencrypt ? null : <button onClick={() => shared.dontReencryptData()} style={theme.buttonStyle}>{_('Ignore')}</button> }
</div>
);
}
render() {
const theme = themeStyle(this.props.theme);
const masterKeys = this.state.masterKeys;
const masterKeys = this.props.masterKeys;
const containerPadding = 10;
const containerStyle = Object.assign({}, theme.containerStyle, {
@ -139,6 +194,9 @@ class EncryptionConfigScreenComponent extends React.Component {
</button>
);
const needUpgradeSection = this.renderNeedUpgradeSection();
const reencryptDataSection = this.renderReencryptData();
let masterKeySection = null;
if (mkComps.length) {
@ -218,8 +276,11 @@ class EncryptionConfigScreenComponent extends React.Component {
</p>
{decryptedItemsInfo}
{toggleButton}
{needUpgradeSection}
{this.props.shouldReencrypt ? reencryptDataSection : null}
{masterKeySection}
{nonExistingMasterKeySection}
{!this.props.shouldReencrypt ? reencryptDataSection : null}
</div>
</div>
);
@ -233,6 +294,7 @@ const mapStateToProps = state => {
passwords: state.settings['encryption.passwordCache'],
encryptionEnabled: state.settings['encryption.enabled'],
activeMasterKeyId: state.settings['encryption.activeMasterKeyId'],
shouldReencrypt: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES,
notLoadedMasterKeys: state.notLoadedMasterKeys,
};
};

View File

@ -22,6 +22,7 @@ const eventManager = require('../eventManager');
const VerticalResizer = require('./VerticalResizer.min');
const PluginManager = require('lib/services/PluginManager');
const TemplateUtils = require('lib/TemplateUtils');
const EncryptionService = require('lib/services/EncryptionService');
class MainScreenComponent extends React.Component {
constructor() {
@ -477,6 +478,85 @@ class MainScreenComponent extends React.Component {
return this.styles_;
}
renderNotification(theme, styles) {
if (!this.messageBoxVisible()) return null;
const onViewStatusScreen = () => {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Status',
});
};
const onViewEncryptionConfigScreen = () => {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: {
defaultSection: 'encryption',
},
});
};
let msg = null;
if (this.props.hasDisabledSyncItems) {
msg = (
<span>
{_('Some items cannot be synchronised.')}{' '}
<a href="#" onClick={() => onViewStatusScreen()}>
{_('View them now')}
</a>
</span>
);
} else if (this.props.hasDisabledEncryptionItems) {
msg = (
<span>
{_('Some items cannot be decrypted.')}{' '}
<a href="#" onClick={() => onViewStatusScreen()}>
{_('View them now')}
</a>
</span>
);
} else if (this.props.showMissingMasterKeyMessage) {
msg = (
<span>
{_('One or more master keys need a password.')}{' '}
<a href="#" onClick={() => onViewEncryptionConfigScreen()}>
{_('Set the password')}
</a>
</span>
);
} else if (this.props.showNeedUpgradingMasterKeyMessage) {
msg = (
<span>
{_('One of your master keys use an obsolete encryption method.')}{' '}
<a href="#" onClick={() => onViewEncryptionConfigScreen()}>
{_('View them now')}
</a>
</span>
);
} else if (this.props.showShouldReencryptMessage) {
msg = (
<span>
{_('The default encryption method has been changed, you should reencrypt your data.')}{' '}
<a href="#" onClick={() => onViewEncryptionConfigScreen()}>
{_('More info')}
</a>
</span>
);
}
return (
<div style={styles.messageBox}>
<span style={theme.textStyle}>{msg}</span>
</div>
);
}
messageBoxVisible() {
return this.props.hasDisabledSyncItems || this.props.showMissingMasterKeyMessage || this.props.showNeedUpgradingMasterKeyMessage || this.props.showShouldReencryptMessage || this.props.hasDisabledEncryptionItems;
}
render() {
const theme = themeStyle(this.props.theme);
const style = Object.assign(
@ -489,10 +569,9 @@ 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 || this.props.showMissingMasterKeyMessage;
const sidebarVisibility = this.props.sidebarVisibility;
const noteListVisibility = this.props.noteListVisibility;
const styles = this.styles(this.props.theme, style.width, style.height, messageBoxVisible, sidebarVisibility, noteListVisibility, this.props.sidebarWidth, this.props.noteListWidth);
const styles = this.styles(this.props.theme, style.width, style.height, this.messageBoxVisible(), sidebarVisibility, noteListVisibility, this.props.sidebarWidth, this.props.noteListWidth);
const onConflictFolder = this.props.selectedFolderId === Folder.conflictFolderId();
const headerItems = [];
@ -565,63 +644,7 @@ class MainScreenComponent extends React.Component {
};
}
const onViewDisabledItemsClick = () => {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Status',
});
};
const onViewMasterKeysClick = () => {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: {
defaultSection: 'encryption',
},
});
};
let messageComp = null;
if (messageBoxVisible) {
let msg = null;
if (this.props.hasDisabledSyncItems) {
msg = (
<span>
{_('Some items cannot be synchronised.')}{' '}
<a
href="#"
onClick={() => {
onViewDisabledItemsClick();
}}
>
{_('View them now')}
</a>
</span>
);
} else if (this.props.showMissingMasterKeyMessage) {
msg = (
<span>
{_('One or more master keys need a password.')}{' '}
<a
href="#"
onClick={() => {
onViewMasterKeysClick();
}}
>
{_('Set the password')}
</a>
</span>
);
}
messageComp = (
<div style={styles.messageBox}>
<span style={theme.textStyle}>{msg}</span>
</div>
);
}
const messageComp = this.renderNotification(theme, styles);
const dialogInfo = PluginManager.instance().pluginDialogToShow(this.props.plugins);
const pluginDialog = !dialogInfo ? null : <dialogInfo.Dialog {...dialogInfo.props} />;
@ -672,7 +695,10 @@ const mapStateToProps = state => {
folders: state.folders,
notes: state.notes,
hasDisabledSyncItems: state.hasDisabledSyncItems,
hasDisabledEncryptionItems: state.hasDisabledEncryptionItems,
showMissingMasterKeyMessage: state.notLoadedMasterKeys.length && state.masterKeys.length,
showNeedUpgradingMasterKeyMessage: !!EncryptionService.instance().masterKeysThatNeedUpgrading(state.masterKeys).length,
showShouldReencryptMessage: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES,
selectedFolderId: state.selectedFolderId,
sidebarWidth: state.settings['style.sidebar.width'],
noteListWidth: state.settings['style.noteList.width'],

View File

@ -96,12 +96,6 @@ export default function ShareNoteDialog(props:ShareNoteDialogProps) {
clipboard.writeText(links.join('\n'));
};
const synchronize = async () => {
const synchronizer = await reg.syncTarget().synchronizer();
await synchronizer.waitForSyncToFinish();
await reg.scheduleSync(0);
};
const shareLinkButton_click = async () => {
let hasSynced = false;
let tryToSync = false;
@ -109,7 +103,7 @@ export default function ShareNoteDialog(props:ShareNoteDialogProps) {
try {
if (tryToSync) {
setSharesState('synchronizing');
await synchronize();
await reg.waitForSyncFinishedThenSync();
tryToSync = false;
hasSynced = true;
}
@ -136,7 +130,7 @@ export default function ShareNoteDialog(props:ShareNoteDialogProps) {
if (sharedStatusChanged) {
setSharesState('synchronizing');
await synchronize();
await reg.waitForSyncFinishedThenSync();
setSharesState('creating');
}

View File

@ -63,7 +63,7 @@ globalStyle.buttonStyle = {
border: '1px solid',
minHeight: 26,
minWidth: 80,
maxWidth: 220,
// maxWidth: 220,
paddingLeft: 12,
paddingRight: 12,
paddingTop: 6,

View File

@ -666,6 +666,15 @@ class BaseApplication {
setLocale(Setting.value('locale'));
}
if (Setting.value('encryption.shouldReencrypt') < 0) {
// We suggest reencryption if the user has at least one notebook
// and if encryptino is enabled. This code runs only when shouldReencrypt = -1
// which can be set by a maintenance script for example.
const folderCount = await Folder.count();
const itShould = Setting.value('encryption.enabled') && !!folderCount ? Setting.SHOULD_REENCRYPT_YES : Setting.SHOULD_REENCRYPT_NO;
Setting.setValue('encryption.shouldReencrypt', itShould);
}
if ('welcomeDisabled' in initArgs) Setting.setValue('welcome.enabled', !initArgs.welcomeDisabled);
if (!Setting.value('api.token')) {

View File

@ -395,6 +395,10 @@ class ConfigScreenComponent extends BaseScreenComponent {
);
} else if (md.type == Setting.TYPE_INT) {
const unitLabel = md.unitLabel ? md.unitLabel(value) : value;
// Note: Do NOT add the minimumTrackTintColor and maximumTrackTintColor props
// on the Slider as they are buggy and can crash the app on certain devices.
// https://github.com/laurent22/joplin/issues/2733
// https://github.com/react-native-community/react-native-slider/issues/161
return (
<View key={key} style={this.styles().settingContainer}>
<Text key="label" style={this.styles().settingText}>
@ -406,7 +410,6 @@ class ConfigScreenComponent extends BaseScreenComponent {
</View>
</View>
);
// minimumTrackTintColor={theme.color} maximumTrackTintColor={theme.color}
} else if (md.type == Setting.TYPE_STRING) {
return (
<View key={key} style={this.styles().settingContainer}>

View File

@ -31,10 +31,6 @@ class EncryptionConfigScreenComponent extends BaseScreenComponent {
this.styles_ = {};
}
componentDidMount() {
this.isMounted_ = true;
}
componentWillUnmount() {
this.isMounted_ = false;
}
@ -47,12 +43,13 @@ class EncryptionConfigScreenComponent extends BaseScreenComponent {
return shared.refreshStats(this);
}
UNSAFE_componentWillMount() {
this.initState(this.props);
componentDidMount() {
this.isMounted_ = true;
shared.componentDidMount(this);
}
UNSAFE_componentWillReceiveProps(nextProps) {
this.initState(nextProps);
componentDidUpdate(prevProps) {
shared.componentDidUpdate(this, prevProps);
}
async checkPasswords() {
@ -110,7 +107,7 @@ class EncryptionConfigScreenComponent extends BaseScreenComponent {
return shared.onPasswordChange(this, mk, text);
};
const password = this.state.passwords[mk.id] ? this.state.passwords[mk.id] : '';
const password = this.props.passwords[mk.id] ? this.props.passwords[mk.id] : '';
const passwordOk = this.state.passwordChecks[mk.id] === true ? '✔' : '❌';
const inputStyle = { flex: 1, marginRight: 10, color: theme.color };
@ -196,7 +193,7 @@ class EncryptionConfigScreenComponent extends BaseScreenComponent {
render() {
const theme = themeStyle(this.props.theme);
const masterKeys = this.state.masterKeys;
const masterKeys = this.props.masterKeys;
const decryptedItemsInfo = this.props.encryptionEnabled ? <Text style={this.styles().normalText}>{shared.decryptedStatText(this)}</Text> : null;
const mkComps = [];

View File

@ -2,13 +2,13 @@ const EncryptionService = require('lib/services/EncryptionService');
const { _ } = require('lib/locale.js');
const BaseItem = require('lib/models/BaseItem.js');
const Setting = require('lib/models/Setting.js');
const MasterKey = require('lib/models/MasterKey.js');
const { reg } = require('lib/registry.js');
const shared = {};
shared.constructor = function(comp) {
comp.state = {
masterKeys: [],
passwords: {},
passwordChecks: {},
stats: {
encrypted: null,
@ -17,47 +17,86 @@ shared.constructor = function(comp) {
};
comp.isMounted_ = false;
comp.refreshStatsIID_ = null;
};
shared.initState = function(comp, props) {
comp.setState(
{
masterKeys: props.masterKeys,
passwords: props.passwords ? props.passwords : {},
},
() => {
comp.checkPasswords();
}
);
comp.refreshStats();
if (comp.refreshStatsIID_) {
clearInterval(comp.refreshStatsIID_);
comp.refreshStatsIID_ = null;
}
comp.refreshStatsIID_ = setInterval(() => {
if (!comp.isMounted_) {
clearInterval(comp.refreshStatsIID_);
comp.refreshStatsIID_ = null;
return;
}
comp.refreshStats();
}, 3000);
shared.refreshStatsIID_ = null;
};
shared.refreshStats = async function(comp) {
const stats = await BaseItem.encryptedItemsStats();
comp.setState({ stats: stats });
comp.setState({
stats: stats,
});
};
shared.reencryptData = async function() {
const ok = confirm(_('Please confirm that you would like to reencrypt your complete database.'));
if (!ok) return;
await BaseItem.forceSyncAll();
reg.waitForSyncFinishedThenSync();
Setting.setValue('encryption.shouldReencrypt', Setting.SHOULD_REENCRYPT_NO);
alert(_('Your data is going to be reencrypted and synced again.'));
};
shared.dontReencryptData = function() {
Setting.setValue('encryption.shouldReencrypt', Setting.SHOULD_REENCRYPT_NO);
};
shared.upgradeMasterKey = async function(comp, masterKey) {
const passwordCheck = comp.state.passwordChecks[masterKey.id];
if (!passwordCheck) {
alert(_('Please enter your password in the master key list below before upgrading the key.'));
return;
}
try {
const password = comp.props.passwords[masterKey.id];
const newMasterKey = await EncryptionService.instance().upgradeMasterKey(masterKey, password);
await MasterKey.save(newMasterKey);
reg.waitForSyncFinishedThenSync();
alert(_('The master key has been upgraded successfully!'));
} catch (error) {
alert(_('Could not upgrade master key: %s', error.message));
}
};
shared.componentDidMount = async function(comp) {
shared.componentDidUpdate(comp);
shared.refreshStats(comp);
if (shared.refreshStatsIID_) {
clearInterval(shared.refreshStatsIID_);
shared.refreshStatsIID_ = null;
}
shared.refreshStatsIID_ = setInterval(() => {
if (!comp.isMounted_) {
clearInterval(shared.refreshStatsIID_);
shared.refreshStatsIID_ = null;
return;
}
shared.refreshStats(comp);
}, 3000);
};
shared.componentDidUpdate = async function(comp, prevProps = null) {
if (!prevProps || comp.props.masterKeys !== prevProps.masterKeys || comp.props.passwords !== prevProps.passwords) {
comp.checkPasswords();
}
};
shared.componentWillUnmount = function() {
if (shared.refreshStatsIID_) {
clearInterval(shared.refreshStatsIID_);
shared.refreshStatsIID_ = null;
}
};
shared.checkPasswords = async function(comp) {
const passwordChecks = Object.assign({}, comp.state.passwordChecks);
for (let i = 0; i < comp.state.masterKeys.length; i++) {
const mk = comp.state.masterKeys[i];
const password = comp.state.passwords[mk.id];
for (let i = 0; i < comp.props.masterKeys.length; i++) {
const mk = comp.props.masterKeys[i];
const password = comp.props.passwords[mk.id];
const ok = password ? await EncryptionService.instance().checkMasterKeyPassword(mk, password) : false;
passwordChecks[mk.id] = ok;
}
@ -72,7 +111,7 @@ shared.decryptedStatText = function(comp) {
};
shared.onSavePasswordClick = function(comp, mk) {
const password = comp.state.passwords[mk.id];
const password = comp.props.passwords[mk.id];
if (!password) {
Setting.deleteObjectKey('encryption.passwordCache', mk.id);
} else {
@ -83,7 +122,7 @@ shared.onSavePasswordClick = function(comp, mk) {
};
shared.onPasswordChange = function(comp, mk, password) {
const passwords = comp.state.passwords;
const passwords = comp.props.passwords;
passwords[mk.id] = password;
comp.setState({ passwords: passwords });
};

View File

@ -538,22 +538,27 @@ class BaseItem extends BaseModel {
extraWhere = extraWhere.length ? `AND ${extraWhere.join(' AND ')}` : '';
// First get all the items that have never been synced under this sync target
//
// We order them by date descending so that latest modified notes go first.
// In most case it doesn't make a big difference, but when re-syncing the whole
// data set it does. In that case it means the recent notes, those that are likely
// to be modified again, will be synced first, thus avoiding potential conflicts.
let sql = sprintf(
`
let sql = sprintf(`
SELECT %s
FROM %s items
WHERE id NOT IN (
SELECT item_id FROM sync_items WHERE sync_target = %d
)
%s
ORDER BY items.updated_time DESC
LIMIT %d
`,
this.db().escapeFields(fieldNames),
this.db().escapeField(ItemClass.tableName()),
Number(syncTarget),
extraWhere,
limit
this.db().escapeFields(fieldNames),
this.db().escapeField(ItemClass.tableName()),
Number(syncTarget),
extraWhere,
limit
);
let neverSyncedItem = await ItemClass.modelSelectAll(sql);
@ -575,6 +580,7 @@ class BaseItem extends BaseModel {
AND (s.sync_time < items.updated_time OR force_sync = 1)
AND s.sync_disabled = 0
%s
ORDER BY items.updated_time DESC
LIMIT %d
`,
this.db().escapeFields(fieldNames),

View File

@ -18,6 +18,10 @@ class MasterKey extends BaseItem {
return this.modelSelectOne('SELECT * FROM master_keys WHERE created_time >= (SELECT max(created_time) FROM master_keys)');
}
static allWithoutEncryptionMethod(masterKeys, method) {
return masterKeys.filter(m => m.encryption_method !== method);
}
static async save(o, options = null) {
return super.save(o, options).then(item => {
this.dispatch({

View File

@ -392,6 +392,11 @@ class Setting extends BaseModel {
'encryption.enabled': { value: false, type: Setting.TYPE_BOOL, public: false },
'encryption.activeMasterKeyId': { value: '', type: Setting.TYPE_STRING, public: false },
'encryption.passwordCache': { value: {}, type: Setting.TYPE_OBJECT, public: false, secure: true },
'encryption.shouldReencrypt': {
value: -1, // will be set on app startup
type: Setting.TYPE_INT,
public: false,
},
// Deprecated in favour of windowContentZoomFactor
'style.zoom': { value: 100, type: Setting.TYPE_INT, public: false, appTypes: ['desktop'], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
@ -1053,6 +1058,10 @@ Setting.DATE_FORMAT_7 = 'YYYY.MM.DD';
Setting.TIME_FORMAT_1 = 'HH:mm';
Setting.TIME_FORMAT_2 = 'h:mm A';
Setting.SHOULD_REENCRYPT_NO = 0; // Data doesn't need to be reencrypted
Setting.SHOULD_REENCRYPT_YES = 1; // Data should be reencrypted
Setting.SHOULD_REENCRYPT_NOTIFIED = 2; // Data should be reencrypted, and user has been notified
Setting.custom_css_files = {
JOPLIN_APP: 'userchrome.css',
RENDERED_MARKDOWN: 'userstyle.css',

View File

@ -32,6 +32,7 @@ const defaultState = {
sharedData: null,
appState: 'starting',
hasDisabledSyncItems: false,
hasDisabledEncryptionItems: false,
customCss: '',
templates: [],
collapsedFolderIds: [],
@ -743,6 +744,11 @@ const reducer = (state = defaultState, action) => {
newState.hasDisabledSyncItems = true;
break;
case 'ENCRYPTION_HAS_DISABLED_ITEMS':
newState = Object.assign({}, state);
newState.hasDisabledEncryptionItems = action.value;
break;
case 'CLIPPER_SERVER_SET':
{
newState = Object.assign({}, state);

View File

@ -52,6 +52,15 @@ reg.syncTarget = (syncTargetId = null) => {
return target;
};
// This can be used when some data has been modified and we want to make
// sure it gets synced. So we wait for the current sync operation to
// finish (if one is running), then we trigger a sync just after.
reg.waitForSyncFinishedThenSync = async () => {
const synchronizer = await reg.syncTarget().synchronizer();
await synchronizer.waitForSyncToFinish();
await reg.scheduleSync(0);
};
reg.scheduleSync_ = async (delay = null, syncOptions = null) => {
if (delay === null) delay = 1000 * 10;
if (syncOptions === null) syncOptions = {};

View File

@ -133,6 +133,7 @@ class DecryptionWorker {
let excludedIds = [];
this.dispatch({ type: 'ENCRYPTION_HAS_DISABLED_ITEMS', value: false });
this.dispatchReport({ state: 'started' });
try {
@ -163,7 +164,8 @@ class DecryptionWorker {
try {
const decryptCounter = await this.kvStore().incValue(counterKey);
if (decryptCounter > this.maxDecryptionAttempts_) {
this.logger().warn(`DecryptionWorker: ${item.id} decryption has failed more than 2 times - skipping it`);
this.logger().debug(`DecryptionWorker: ${item.id} decryption has failed more than 2 times - skipping it`);
this.dispatch({ type: 'ENCRYPTION_HAS_DISABLED_ITEMS', value: true });
excludedIds.push(item.id);
continue;
}

View File

@ -29,7 +29,7 @@ class EncryptionService {
this.loadedMasterKeys_ = {};
this.activeMasterKeyId_ = null;
this.defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL_1A;
this.defaultMasterKeyEncryptionMethod_ = EncryptionService.METHOD_SJCL_2;
this.defaultMasterKeyEncryptionMethod_ = EncryptionService.METHOD_SJCL_4;
this.logger_ = new Logger();
this.headerTemplates_ = {
@ -107,13 +107,6 @@ class EncryptionService {
if (!password) continue;
try {
// if (mk.encryption_method != this.defaultMasterKeyEncryptionMethod_) {
// const newMkContent = await this.generateMasterKeyContent_(password);
// mk = Object.assign({}, mk, newMkContent);
// await MasterKey.save(mk);
// this.logger().info(`Master key ${mk.id} is using a deprectated encryption method. It has been upgraded to the new method.`);
// }
await this.loadMasterKey_(mk, password, activeMasterKeyId === mk.id);
} catch (error) {
this.logger().warn(`Cannot load master key ${mk.id}. Invalid password?`, error);
@ -229,6 +222,29 @@ class EncryptionService {
.join('');
}
masterKeysThatNeedUpgrading(masterKeys) {
return MasterKey.allWithoutEncryptionMethod(masterKeys, this.defaultMasterKeyEncryptionMethod_);
}
async upgradeMasterKey(model, decryptionPassword) {
const newEncryptionMethod = this.defaultMasterKeyEncryptionMethod_;
const plainText = await this.decryptMasterKey_(model, decryptionPassword);
const newContent = await this.encryptMasterKeyContent_(newEncryptionMethod, plainText, decryptionPassword);
return { ...model, ...newContent };
}
async encryptMasterKeyContent_(encryptionMethod, hexaBytes, password) {
// Checksum is not necessary since decryption will already fail if data is invalid
const checksum = encryptionMethod === EncryptionService.METHOD_SJCL_2 ? this.sha256(hexaBytes) : '';
const cipherText = await this.encrypt(encryptionMethod, password, hexaBytes);
return {
checksum: checksum,
encryption_method: encryptionMethod,
content: cipherText,
};
}
async generateMasterKeyContent_(password, options = null) {
options = Object.assign({}, {
encryptionMethod: this.defaultMasterKeyEncryptionMethod_,
@ -236,14 +252,8 @@ class EncryptionService {
const bytes = await shim.randomBytes(256);
const hexaBytes = bytes.map(a => hexPad(a.toString(16), 2)).join('');
const checksum = this.sha256(hexaBytes);
const cipherText = await this.encrypt(options.encryptionMethod, password, hexaBytes);
return {
checksum: checksum,
encryption_method: options.encryptionMethod,
content: cipherText,
};
return this.encryptMasterKeyContent_(options.encryptionMethod, hexaBytes, password);
}
async generateMasterKey(password, options = null) {
@ -259,8 +269,10 @@ class EncryptionService {
async decryptMasterKey_(model, password) {
const plainText = await this.decrypt(model.encryption_method, password, model.content);
const checksum = this.sha256(plainText);
if (checksum !== model.checksum) throw new Error('Could not decrypt master key (checksum failed)');
if (model.encryption_method === EncryptionService.METHOD_SJCL_2) {
const checksum = this.sha256(plainText);
if (checksum !== model.checksum) throw new Error('Could not decrypt master key (checksum failed)');
}
return plainText;
}