mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
463 lines
16 KiB
TypeScript
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);
|
|
};
|