You've already forked joplin
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:
@ -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')) {
|
||||
|
@ -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}>
|
||||
|
@ -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 = [];
|
||||
|
@ -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 });
|
||||
};
|
||||
|
@ -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),
|
||||
|
@ -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({
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
|
@ -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 = {};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user