2023-09-24 17:26:01 +02:00
import Logger from '@joplin/utils/Logger' ;
2021-08-12 17:54:10 +02:00
import { FileApi } from '../../file-api' ;
import JoplinDatabase from '../../JoplinDatabase' ;
import Setting from '../../models/Setting' ;
import { State } from '../../reducer' ;
2021-10-03 17:00:49 +02:00
import { PublicPrivateKeyPair } from '../e2ee/ppk' ;
2021-08-17 13:03:19 +02:00
import { MasterKeyEntity } from '../e2ee/types' ;
2024-01-26 12:32:35 +02:00
import { compareVersions } from 'compare-versions' ;
import { _ } from '../../locale' ;
import JoplinError from '../../JoplinError' ;
import { ErrorCode } from '../../errors' ;
2021-11-10 16:47:26 +02:00
const fastDeepEqual = require ( 'fast-deep-equal' ) ;
2021-08-12 17:54:10 +02:00
2023-09-24 17:26:01 +02:00
const logger = Logger . create ( 'syncInfoUtils' ) ;
2021-08-12 17:54:10 +02:00
export interface SyncInfoValueBoolean {
value : boolean ;
updatedTime : number ;
}
export interface SyncInfoValueString {
value : string ;
updatedTime : number ;
}
2021-10-03 17:00:49 +02:00
export interface SyncInfoValuePublicPrivateKeyPair {
value : PublicPrivateKeyPair ;
updatedTime : number ;
}
2024-01-26 12:32:35 +02:00
// 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 ;
} ;
2021-08-12 17:54:10 +02:00
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' ) ;
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-08-12 17:54:10 +02:00
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
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-08-12 17:54:10 +02:00
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 } ;
}
2023-09-24 17:26:01 +02:00
return fixSyncInfo ( new SyncInfo ( JSON . stringify ( output ) ) ) ;
2021-08-12 17:54:10 +02:00
}
export function saveLocalSyncInfo ( syncInfo : SyncInfo ) {
Setting . setValue ( 'syncInfoCache' , syncInfo . serialize ( ) ) ;
}
2023-09-24 17:26:01 +02:00
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 ;
} ;
2021-08-12 17:54:10 +02:00
export function localSyncInfo ( ) : SyncInfo {
2024-01-26 12:32:35 +02:00
const output = new SyncInfo ( Setting . value ( 'syncInfoCache' ) ) ;
output . appMinVersion = appMinVersion_ ;
return fixSyncInfo ( output ) ;
2021-08-12 17:54:10 +02:00
}
export function localSyncInfoFromState ( state : State ) : SyncInfo {
return new SyncInfo ( state . settings [ 'syncInfoCache' ] ) ;
}
2022-04-13 13:18:38 +02:00
// 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-31 21:16:17 +02:00
//
// 2023-05-30: Additionally, if one key is enabled and the other is not, we
// always pick the enabled one regardless of usage.
2022-04-13 13:18:38 +02:00
const mergeActiveMasterKeys = ( s1 : SyncInfo , s2 : SyncInfo , output : SyncInfo ) = > {
const activeMasterKey1 = getActiveMasterKey ( s1 ) ;
const activeMasterKey2 = getActiveMasterKey ( s2 ) ;
let doDefaultAction = false ;
if ( activeMasterKey1 && activeMasterKey2 ) {
2023-05-31 21:16:17 +02:00
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 ) {
2022-04-13 13:18:38 +02:00
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' ) ;
}
} ;
2024-02-02 19:53:22 +02:00
// If there is a distinction, s1 should be local sync info and s2 remote.
2021-08-12 17:54:10 +02:00
export function mergeSyncInfos ( s1 : SyncInfo , s2 : SyncInfo ) : SyncInfo {
const output : SyncInfo = new SyncInfo ( ) ;
output . setWithTimestamp ( s1 . keyTimestamp ( 'e2ee' ) > s2 . keyTimestamp ( 'e2ee' ) ? s1 : s2 , 'e2ee' ) ;
2021-10-03 17:00:49 +02:00
output . setWithTimestamp ( s1 . keyTimestamp ( 'ppk' ) > s2 . keyTimestamp ( 'ppk' ) ? s1 : s2 , 'ppk' ) ;
2021-08-12 17:54:10 +02:00
output . version = s1 . version > s2 . version ? s1.version : s2.version ;
2022-04-13 13:18:38 +02:00
mergeActiveMasterKeys ( s1 , s2 , output ) ;
2021-08-12 17:54:10 +02:00
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 ;
}
}
2024-02-02 19:53:22 +02:00
// 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 ;
2024-01-26 12:32:35 +02:00
2021-08-12 17:54:10 +02:00
return output ;
}
export function syncInfoEquals ( s1 : SyncInfo , s2 : SyncInfo ) : boolean {
2021-11-10 16:47:26 +02:00
return fastDeepEqual ( s1 . toObject ( ) , s2 . toObject ( ) ) ;
2021-08-12 17:54:10 +02:00
}
export class SyncInfo {
2023-06-30 10:07:03 +02:00
private version_ = 0 ;
2021-08-12 17:54:10 +02:00
private e2ee_ : SyncInfoValueBoolean ;
private activeMasterKeyId_ : SyncInfoValueString ;
private masterKeys_ : MasterKeyEntity [ ] = [ ] ;
2021-10-03 17:00:49 +02:00
private ppk_ : SyncInfoValuePublicPrivateKeyPair ;
2024-01-26 12:32:35 +02:00
private appMinVersion_ : string = appMinVersion_ ;
2021-08-12 17:54:10 +02:00
public constructor ( serialized : string = null ) {
this . e2ee_ = { value : false , updatedTime : 0 } ;
this . activeMasterKeyId_ = { value : '' , updatedTime : 0 } ;
2021-10-03 17:00:49 +02:00
this . ppk_ = { value : null , updatedTime : 0 } ;
2021-08-12 17:54:10 +02:00
if ( serialized ) this . load ( serialized ) ;
}
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-08-12 17:54:10 +02:00
public toObject ( ) : any {
return {
version : this.version ,
e2ee : this.e2ee_ ,
activeMasterKeyId : this.activeMasterKeyId_ ,
masterKeys : this.masterKeys ,
2021-10-03 17:00:49 +02:00
ppk : this.ppk_ ,
2024-01-26 12:32:35 +02:00
appMinVersion : this.appMinVersion ,
2021-08-12 17:54:10 +02:00
} ;
}
2024-03-02 17:57:29 +02:00
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 ;
}
2021-08-12 17:54:10 +02:00
public serialize ( ) : string {
return JSON . stringify ( this . toObject ( ) , null , '\t' ) ;
}
public load ( serialized : string ) {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-08-12 17:54:10 +02:00
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 : [ ] ;
2021-10-03 17:00:49 +02:00
this . ppk_ = 'ppk' in s ? s . ppk : { value : null , updatedTime : 0 } ;
2024-02-02 19:53:22 +02:00
this . appMinVersion_ = s . appMinVersion ? s . appMinVersion : '0.0.0' ;
2022-04-13 13:18:38 +02:00
// 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 ;
}
}
2021-08-12 17:54:10 +02:00
}
public setWithTimestamp ( fromSyncInfo : SyncInfo , propName : string ) {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-08-12 17:54:10 +02:00
if ( ! ( propName in ( this as any ) ) ) throw new Error ( ` Invalid prop name: ${ propName } ` ) ;
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-08-12 17:54:10 +02:00
( 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 ;
}
2021-10-03 17:00:49 +02:00
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 ( ) } ;
}
2021-08-12 17:54:10 +02:00
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 ( ) } ;
}
2024-01-26 12:32:35 +02:00
public get appMinVersion ( ) : string {
return this . appMinVersion_ ;
}
public set appMinVersion ( v : string ) {
this . appMinVersion_ = v ;
}
2021-08-12 17:54:10 +02:00
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 {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-08-12 17:54:10 +02:00
if ( ! ( ` ${ name } _ ` in ( this as any ) ) ) throw new Error ( ` Invalid name: ${ name } ` ) ;
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-08-12 17:54:10 +02:00
return ( this as any ) [ ` ${ name } _ ` ] . updatedTime ;
}
public setKeyTimestamp ( name : string , timestamp : number ) {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-08-12 17:54:10 +02:00
if ( ! ( ` ${ name } _ ` in ( this as any ) ) ) throw new Error ( ` Invalid name: ${ name } ` ) ;
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-08-12 17:54:10 +02:00
( this as any ) [ ` ${ name } _ ` ] . updatedTime = timestamp ;
}
}
// ---------------------------------------------------------
// Shortcuts to simplify the refactoring
// ---------------------------------------------------------
export function getEncryptionEnabled() {
return localSyncInfo ( ) . e2ee ;
}
2023-06-30 10:11:26 +02:00
export function setEncryptionEnabled ( v : boolean , activeMasterKeyId = '' ) {
2021-08-12 17:54:10 +02:00
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 ) ;
}
2021-08-17 13:03:19 +02:00
2023-06-30 10:11:26 +02:00
export function setMasterKeyEnabled ( mkId : string , enabled = true ) {
2021-08-17 13:03:19 +02:00
const s = localSyncInfo ( ) ;
const idx = s . masterKeys . findIndex ( mk = > mk . id === mkId ) ;
if ( idx < 0 ) throw new Error ( ` No such master key: ${ mkId } ` ) ;
2021-09-06 19:33:17 +02:00
// 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');
2021-08-17 13:03:19 +02:00
s . masterKeys [ idx ] = {
. . . s . masterKeys [ idx ] ,
enabled : enabled ? 1 : 0 ,
updated_time : Date.now ( ) ,
} ;
saveLocalSyncInfo ( s ) ;
}
2022-04-13 13:18:38 +02:00
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 ;
} ;
2021-08-17 13:03:19 +02:00
export function masterKeyEnabled ( mk : MasterKeyEntity ) : boolean {
if ( 'enabled' in mk ) return ! ! mk . enabled ;
return true ;
}
2021-10-03 17:00:49 +02:00
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 ) ;
}
2024-01-26 12:32:35 +02:00
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 ) ;
} ;