1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-06-21 23:17:42 +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

@ -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;
}