1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-27 10:32:58 +02:00
joplin/packages/lib/services/synchronizer/syncInfoUtils.ts

463 lines
16 KiB
TypeScript

import Logger from '@joplin/utils/Logger';
import { FileApi } from '../../file-api';
import JoplinDatabase from '../../JoplinDatabase';
import Setting from '../../models/Setting';
import { State } from '../../reducer';
import { PublicPrivateKeyPair } from '../e2ee/ppk';
import { MasterKeyEntity } from '../e2ee/types';
import { compareVersions } from 'compare-versions';
import { _ } from '../../locale';
import JoplinError from '../../JoplinError';
import { ErrorCode } from '../../errors';
const fastDeepEqual = require('fast-deep-equal');
const logger = Logger.create('syncInfoUtils');
export interface SyncInfoValueBoolean {
value: boolean;
updatedTime: number;
}
export interface SyncInfoValueString {
value: string;
updatedTime: number;
}
export interface SyncInfoValuePublicPrivateKeyPair {
value: PublicPrivateKeyPair;
updatedTime: number;
}
// This should be set to the client version whenever we require all the clients to be at the same
// version in order to synchronise. One example is when adding support for the trash feature - if an
// old client that doesn't know about this feature synchronises data with a new client, the notes
// will no longer be deleted on the old client.
//
// Usually this variable should be bumped whenever we add properties to a sync item.
//
// `appMinVersion_` should really just be a constant but for testing purposes it can be changed
// using `setAppMinVersion()`
let appMinVersion_ = '0.0.0';
export const setAppMinVersion = (v: string) => {
appMinVersion_ = v;
};
export async function migrateLocalSyncInfo(db: JoplinDatabase) {
if (Setting.value('syncInfoCache')) return; // Already initialized
// TODO: if the sync info is changed, there should be steps to migrate from
// v3 to v4, v4 to v5, etc.
const masterKeys = await db.selectAll('SELECT * FROM master_keys');
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const masterKeyMap: Record<string, any> = {};
for (const mk of masterKeys) masterKeyMap[mk.id] = mk;
const syncInfo = new SyncInfo();
syncInfo.version = Setting.value('syncVersion');
syncInfo.e2ee = Setting.valueNoThrow('encryption.enabled', false);
syncInfo.activeMasterKeyId = Setting.valueNoThrow('encryption.activeMasterKeyId', '');
syncInfo.masterKeys = masterKeys;
// We set the timestamp to 0 because we don't know when the source setting
// has been set. That way, if the parameter is changed later on in any
// client, the new value will have higher priority. This is to handle this
// case:
//
// - Client 1 upgrade local sync target info (with E2EE = false)
// - Client 1 set E2EE to true
// - Client 2 upgrade local sync target info (with E2EE = false)
// - => If we don't set the timestamp to 0, the local value of client 2 will
// have a higher timestamp and E2EE will get disabled, even though this is
// most likely not what the user wants.
syncInfo.setKeyTimestamp('e2ee', 0);
syncInfo.setKeyTimestamp('activeMasterKeyId', 0);
await saveLocalSyncInfo(syncInfo);
}
export async function uploadSyncInfo(api: FileApi, syncInfo: SyncInfo) {
await api.put('info.json', syncInfo.serialize());
}
export async function fetchSyncInfo(api: FileApi): Promise<SyncInfo> {
const syncTargetInfoText = await api.get('info.json');
// Returns version 0 if the sync target is empty
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let output: any = { version: 0 };
if (syncTargetInfoText) {
output = JSON.parse(syncTargetInfoText);
if (!output.version) throw new Error('Missing "version" field in info.json');
} else {
// If info.json is not present, this might be an old sync target, in
// which case we can at least get the version number from version.txt
const oldVersion = await api.get('.sync/version.txt');
if (oldVersion) output = { version: 1 };
}
return fixSyncInfo(new SyncInfo(JSON.stringify(output)));
}
export function saveLocalSyncInfo(syncInfo: SyncInfo) {
Setting.setValue('syncInfoCache', syncInfo.serialize());
}
const fixSyncInfo = (syncInfo: SyncInfo) => {
if (syncInfo.activeMasterKeyId) {
if (!syncInfo.masterKeys || !syncInfo.masterKeys.find(mk => mk.id === syncInfo.activeMasterKeyId)) {
logger.warn(`Sync info is using a non-existent key as the active key - clearing it: ${syncInfo.activeMasterKeyId}`);
syncInfo.activeMasterKeyId = '';
}
}
return syncInfo;
};
export function localSyncInfo(): SyncInfo {
const output = new SyncInfo(Setting.value('syncInfoCache'));
output.appMinVersion = appMinVersion_;
return fixSyncInfo(output);
}
export function localSyncInfoFromState(state: State): SyncInfo {
return new SyncInfo(state.settings['syncInfoCache']);
}
// When deciding which master key should be active we should take into account
// whether it's been used or not. If it's been used before it should most likely
// remain the active one, regardless of timestamps. This is because the extra
// key was most likely created by mistake by the user, in particular in this
// kind of scenario:
//
// - Client 1 setup sync with sync target
// - Client 1 enable encryption
// - Client 1 sync
//
// Then user 2 does the same:
//
// - Client 2 setup sync with sync target
// - Client 2 enable encryption
// - Client 2 sync
//
// The problem is that enabling encryption was not needed since it was already
// done (and recorded in info.json) on the sync target. As a result an extra key
// has been created and it has been set as the active one, but we shouldn't use
// it. Instead the key created by client 1 should be used and made active again.
//
// And we can do this using the "hasBeenUsed" field which tells us which keys
// has already been used to encrypt data. In this case, at the moment we compare
// local and remote sync info (before synchronising the data), key1.hasBeenUsed
// is true, but key2.hasBeenUsed is false.
//
// 2023-05-30: Additionally, if one key is enabled and the other is not, we
// always pick the enabled one regardless of usage.
const mergeActiveMasterKeys = (s1: SyncInfo, s2: SyncInfo, output: SyncInfo) => {
const activeMasterKey1 = getActiveMasterKey(s1);
const activeMasterKey2 = getActiveMasterKey(s2);
let doDefaultAction = false;
if (activeMasterKey1 && activeMasterKey2) {
if (masterKeyEnabled(activeMasterKey1) && !masterKeyEnabled(activeMasterKey2)) {
output.setWithTimestamp(s1, 'activeMasterKeyId');
} else if (!masterKeyEnabled(activeMasterKey1) && masterKeyEnabled(activeMasterKey2)) {
output.setWithTimestamp(s2, 'activeMasterKeyId');
} else if (activeMasterKey1.hasBeenUsed && !activeMasterKey2.hasBeenUsed) {
output.setWithTimestamp(s1, 'activeMasterKeyId');
} else if (!activeMasterKey1.hasBeenUsed && activeMasterKey2.hasBeenUsed) {
output.setWithTimestamp(s2, 'activeMasterKeyId');
} else {
doDefaultAction = true;
}
} else {
doDefaultAction = true;
}
if (doDefaultAction) {
output.setWithTimestamp(s1.keyTimestamp('activeMasterKeyId') > s2.keyTimestamp('activeMasterKeyId') ? s1 : s2, 'activeMasterKeyId');
}
};
// If there is a distinction, s1 should be local sync info and s2 remote.
export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo): SyncInfo {
const output: SyncInfo = new SyncInfo();
output.setWithTimestamp(s1.keyTimestamp('e2ee') > s2.keyTimestamp('e2ee') ? s1 : s2, 'e2ee');
output.setWithTimestamp(s1.keyTimestamp('ppk') > s2.keyTimestamp('ppk') ? s1 : s2, 'ppk');
output.version = s1.version > s2.version ? s1.version : s2.version;
mergeActiveMasterKeys(s1, s2, output);
output.masterKeys = s1.masterKeys.slice();
for (const mk of s2.masterKeys) {
const idx = output.masterKeys.findIndex(m => m.id === mk.id);
if (idx < 0) {
output.masterKeys.push(mk);
} else {
const mk2 = output.masterKeys[idx];
output.masterKeys[idx] = mk.updated_time > mk2.updated_time ? mk : mk2;
}
}
// We use >= so that the version from s1 (local) is preferred to the version in s2 (remote).
// For example, if s2 has appMinVersion 0.00 and s1 has appMinVersion 0.0.0, we choose the
// local version, 0.0.0.
output.appMinVersion = compareVersions(s1.appMinVersion, s2.appMinVersion) >= 0 ? s1.appMinVersion : s2.appMinVersion;
return output;
}
export function syncInfoEquals(s1: SyncInfo, s2: SyncInfo): boolean {
return fastDeepEqual(s1.toObject(), s2.toObject());
}
export class SyncInfo {
private version_ = 0;
private e2ee_: SyncInfoValueBoolean;
private activeMasterKeyId_: SyncInfoValueString;
private masterKeys_: MasterKeyEntity[] = [];
private ppk_: SyncInfoValuePublicPrivateKeyPair;
private appMinVersion_: string = appMinVersion_;
public constructor(serialized: string = null) {
this.e2ee_ = { value: false, updatedTime: 0 };
this.activeMasterKeyId_ = { value: '', updatedTime: 0 };
this.ppk_ = { value: null, updatedTime: 0 };
if (serialized) this.load(serialized);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public toObject(): any {
return {
version: this.version,
e2ee: this.e2ee_,
activeMasterKeyId: this.activeMasterKeyId_,
masterKeys: this.masterKeys,
ppk: this.ppk_,
appMinVersion: this.appMinVersion,
};
}
public filterSyncInfo() {
const filtered = JSON.parse(JSON.stringify(this.toObject()));
// Filter content and checksum properties from master keys
if (filtered.masterKeys) {
filtered.masterKeys = filtered.masterKeys.map((mk: MasterKeyEntity) => {
delete mk.content;
delete mk.checksum;
return mk;
});
}
// Truncate the private key and public key
if (filtered.ppk.value) {
filtered.ppk.value.privateKey.ciphertext = `${filtered.ppk.value.privateKey.ciphertext.substr(0, 20)}...${filtered.ppk.value.privateKey.ciphertext.substr(-20)}`;
filtered.ppk.value.publicKey = `${filtered.ppk.value.publicKey.substr(0, 40)}...`;
}
return filtered;
}
public serialize(): string {
return JSON.stringify(this.toObject(), null, '\t');
}
public load(serialized: string) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const s: any = JSON.parse(serialized);
this.version = 'version' in s ? s.version : 0;
this.e2ee_ = 'e2ee' in s ? s.e2ee : { value: false, updatedTime: 0 };
this.activeMasterKeyId_ = 'activeMasterKeyId' in s ? s.activeMasterKeyId : { value: '', updatedTime: 0 };
this.masterKeys_ = 'masterKeys' in s ? s.masterKeys : [];
this.ppk_ = 'ppk' in s ? s.ppk : { value: null, updatedTime: 0 };
this.appMinVersion_ = s.appMinVersion ? s.appMinVersion : '0.0.0';
// Migration for master keys that didn't have "hasBeenUsed" property -
// in that case we assume they've been used at least once.
for (const mk of this.masterKeys_) {
if (!('hasBeenUsed' in mk) || mk.hasBeenUsed === undefined) {
mk.hasBeenUsed = true;
}
}
}
public setWithTimestamp(fromSyncInfo: SyncInfo, propName: string) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
if (!(propName in (this as any))) throw new Error(`Invalid prop name: ${propName}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(this as any)[propName] = (fromSyncInfo as any)[propName];
this.setKeyTimestamp(propName, fromSyncInfo.keyTimestamp(propName));
}
public get version(): number {
return this.version_;
}
public set version(v: number) {
if (v === this.version_) return;
this.version_ = v;
}
public get ppk() {
return this.ppk_.value;
}
public set ppk(v: PublicPrivateKeyPair) {
if (v === this.ppk_.value) return;
this.ppk_ = { value: v, updatedTime: Date.now() };
}
public get e2ee(): boolean {
return this.e2ee_.value;
}
public set e2ee(v: boolean) {
if (v === this.e2ee) return;
this.e2ee_ = { value: v, updatedTime: Date.now() };
}
public get appMinVersion(): string {
return this.appMinVersion_;
}
public set appMinVersion(v: string) {
this.appMinVersion_ = v;
}
public get activeMasterKeyId(): string {
return this.activeMasterKeyId_.value;
}
public set activeMasterKeyId(v: string) {
if (v === this.activeMasterKeyId) return;
this.activeMasterKeyId_ = { value: v, updatedTime: Date.now() };
}
public get masterKeys(): MasterKeyEntity[] {
return this.masterKeys_;
}
public set masterKeys(v: MasterKeyEntity[]) {
if (JSON.stringify(v) === JSON.stringify(this.masterKeys_)) return;
this.masterKeys_ = v;
}
public keyTimestamp(name: string): number {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
if (!(`${name}_` in (this as any))) throw new Error(`Invalid name: ${name}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return (this as any)[`${name}_`].updatedTime;
}
public setKeyTimestamp(name: string, timestamp: number) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
if (!(`${name}_` in (this as any))) throw new Error(`Invalid name: ${name}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(this as any)[`${name}_`].updatedTime = timestamp;
}
}
// ---------------------------------------------------------
// Shortcuts to simplify the refactoring
// ---------------------------------------------------------
export function getEncryptionEnabled() {
return localSyncInfo().e2ee;
}
export function setEncryptionEnabled(v: boolean, activeMasterKeyId = '') {
const s = localSyncInfo();
s.e2ee = v;
if (activeMasterKeyId) s.activeMasterKeyId = activeMasterKeyId;
saveLocalSyncInfo(s);
}
export function getActiveMasterKeyId() {
return localSyncInfo().activeMasterKeyId;
}
export function setActiveMasterKeyId(id: string) {
const s = localSyncInfo();
s.activeMasterKeyId = id;
saveLocalSyncInfo(s);
}
export function getActiveMasterKey(s: SyncInfo = null): MasterKeyEntity | null {
s = s || localSyncInfo();
if (!s.activeMasterKeyId) return null;
return s.masterKeys.find(mk => mk.id === s.activeMasterKeyId);
}
export function setMasterKeyEnabled(mkId: string, enabled = true) {
const s = localSyncInfo();
const idx = s.masterKeys.findIndex(mk => mk.id === mkId);
if (idx < 0) throw new Error(`No such master key: ${mkId}`);
// Disabled for now as it's needed to disable even the main master key when the password has been forgotten
// https://discourse.joplinapp.org/t/syncing-error-with-joplin-cloud-and-e2ee-master-key-is-not-loaded/20115/5?u=laurent
//
// if (mkId === getActiveMasterKeyId() && !enabled) throw new Error('The active master key cannot be disabled');
s.masterKeys[idx] = {
...s.masterKeys[idx],
enabled: enabled ? 1 : 0,
updated_time: Date.now(),
};
saveLocalSyncInfo(s);
}
export const setMasterKeyHasBeenUsed = (s: SyncInfo, mkId: string) => {
const idx = s.masterKeys.findIndex(mk => mk.id === mkId);
if (idx < 0) throw new Error(`No such master key: ${mkId}`);
s.masterKeys[idx] = {
...s.masterKeys[idx],
hasBeenUsed: true,
updated_time: Date.now(),
};
saveLocalSyncInfo(s);
return s;
};
export function masterKeyEnabled(mk: MasterKeyEntity): boolean {
if ('enabled' in mk) return !!mk.enabled;
return true;
}
export function addMasterKey(syncInfo: SyncInfo, masterKey: MasterKeyEntity) {
// Sanity check - because shouldn't happen
if (syncInfo.masterKeys.find(mk => mk.id === masterKey.id)) throw new Error('Master key is already present');
syncInfo.masterKeys.push(masterKey);
saveLocalSyncInfo(syncInfo);
}
export function setPpk(ppk: PublicPrivateKeyPair) {
const syncInfo = localSyncInfo();
syncInfo.ppk = ppk;
saveLocalSyncInfo(syncInfo);
}
export function masterKeyById(id: string) {
return localSyncInfo().masterKeys.find(mk => mk.id === id);
}
export const checkIfCanSync = (s: SyncInfo, appVersion: string) => {
if (compareVersions(appVersion, s.appMinVersion) < 0) throw new JoplinError(_('In order to synchronise, please upgrade your application to version %s+', s.appMinVersion), ErrorCode.MustUpgradeApp);
};