diff --git a/.gitignore b/.gitignore
index 30ff3761b3..b786c37d91 100644
--- a/.gitignore
+++ b/.gitignore
@@ -426,6 +426,8 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
+packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.test.js
+packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.js
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js
@@ -950,6 +952,8 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
packages/app-mobile/components/screens/Note/commands/setTags.js
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
packages/app-mobile/components/screens/Note/types.js
+packages/app-mobile/components/screens/NoteLockMigrationScreen.test.js
+packages/app-mobile/components/screens/NoteLockMigrationScreen.js
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
packages/app-mobile/components/screens/NoteRevisionViewer.js
packages/app-mobile/components/screens/NoteTagsDialog.js
@@ -1804,6 +1808,8 @@ packages/lib/services/noteList/renderViewProps.test.js
packages/lib/services/noteList/renderViewProps.js
packages/lib/services/noteList/renderers.js
packages/lib/services/noteLock/NoteLockKey.js
+packages/lib/services/noteLock/NoteLockKeyMigration.test.js
+packages/lib/services/noteLock/NoteLockKeyMigration.js
packages/lib/services/noteLock/NoteLockNote.js
packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
@@ -1965,6 +1971,7 @@ packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
packages/lib/services/synchronizer/Synchronizer.basics.test.js
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
+packages/lib/services/synchronizer/Synchronizer.noteLock.test.js
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
packages/lib/services/synchronizer/Synchronizer.resources.test.js
packages/lib/services/synchronizer/Synchronizer.revisions.test.js
diff --git a/.ignore.eslint b/.ignore.eslint
index b0123dbbb3..61a90d08d6 100644
--- a/.ignore.eslint
+++ b/.ignore.eslint
@@ -452,6 +452,8 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
+packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.test.js
+packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.js
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js
@@ -976,6 +978,8 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
packages/app-mobile/components/screens/Note/commands/setTags.js
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
packages/app-mobile/components/screens/Note/types.js
+packages/app-mobile/components/screens/NoteLockMigrationScreen.test.js
+packages/app-mobile/components/screens/NoteLockMigrationScreen.js
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
packages/app-mobile/components/screens/NoteRevisionViewer.js
packages/app-mobile/components/screens/NoteTagsDialog.js
@@ -1830,6 +1834,8 @@ packages/lib/services/noteList/renderViewProps.test.js
packages/lib/services/noteList/renderViewProps.js
packages/lib/services/noteList/renderers.js
packages/lib/services/noteLock/NoteLockKey.js
+packages/lib/services/noteLock/NoteLockKeyMigration.test.js
+packages/lib/services/noteLock/NoteLockKeyMigration.js
packages/lib/services/noteLock/NoteLockNote.js
packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
@@ -1991,6 +1997,7 @@ packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
packages/lib/services/synchronizer/Synchronizer.basics.test.js
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
+packages/lib/services/synchronizer/Synchronizer.noteLock.test.js
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
packages/lib/services/synchronizer/Synchronizer.resources.test.js
packages/lib/services/synchronizer/Synchronizer.revisions.test.js
diff --git a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx
index 1368e8bbbf..a99e2037de 100644
--- a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx
+++ b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx
@@ -787,6 +787,7 @@ function NoteEditorContent(props: NoteEditorProps) {
noteTitle={lockedNoteMetadata.title}
hasNoteLockKey={props.hasNoteLockKey}
dispatch={props.dispatch}
+ migrationRunning={props.noteLockMigrationRunning}
lockedInShare={noteIsLockedInShare(lockedNoteMetadata)}
/>
@@ -921,6 +922,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
whiteboardForceMarkdown: windowState.whiteboardForceMarkdown ?? {},
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
hasNoteLockKey: hasNoteLockKey(state.settings['syncInfoCache']),
+ noteLockMigrationRunning: !!state.noteLockMigrationStatus?.running,
editorNoteReloadTimeRequest: windowState.windowEditorNoteReloadTimeRequest,
};
};
diff --git a/packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.tsx b/packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.tsx
index 8f7f7d4c1f..014757299c 100644
--- a/packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.tsx
+++ b/packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.tsx
@@ -11,6 +11,7 @@ interface Props {
hasNoteLockKey: boolean;
dispatch: Dispatch;
undecryptable?: boolean;
+ migrationRunning?: boolean;
lockedInShare?: boolean;
}
@@ -50,6 +51,10 @@ export default function NoteLockPanel(props: Props) {
}, [props.dispatch]);
const renderAction = () => {
+ if (props.migrationRunning) {
+ return
{_('This note cannot be read while a note lock key migration is in progress.')}
;
+ }
+
if (props.undecryptable) {
return {_('This note could not be unlocked. If it was locked prior to a password reset, the content is no longer recoverable.')}
;
}
diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts
index 9b2ccf9893..129028cbdf 100644
--- a/packages/app-desktop/gui/NoteEditor/utils/types.ts
+++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts
@@ -76,6 +76,7 @@ export interface NoteEditorProps {
whiteboardForceMarkdown: Record;
noteLockSessionUnlocked: boolean;
hasNoteLockKey: boolean;
+ noteLockMigrationRunning: boolean;
editorNoteReloadTimeRequest: number;
}
diff --git a/packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.test.tsx b/packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.test.tsx
new file mode 100644
index 0000000000..edd93294dc
--- /dev/null
+++ b/packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.test.tsx
@@ -0,0 +1,68 @@
+import * as React from 'react';
+import * as ReactDom from 'react-dom';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import shim from '@joplin/lib/shim';
+import NoteLockMigrationDialog from './Dialog';
+import { confirmAndSkipNoteLockKeyMigration, startNoteLockKeyMigration } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
+
+jest.mock('@joplin/lib/services/noteLock/NoteLockKeyMigration', () => ({
+ startNoteLockKeyMigration: jest.fn(),
+ confirmAndSkipNoteLockKeyMigration: jest.fn(),
+}));
+
+// The dialog renders through a portal, jsdom has no modal dialog support, and DialogTitle relies on
+// the React global the app bundle provides.
+shim.setReactDom(ReactDom);
+(globalThis as unknown as { React: typeof React }).React = React;
+HTMLDialogElement.prototype.showModal = function() { this.open = true; };
+HTMLDialogElement.prototype.close = function() { this.open = false; };
+
+const startMock = startNoteLockKeyMigration as jest.Mock;
+const skipMock = confirmAndSkipNoteLockKeyMigration as jest.Mock;
+
+const renderDialog = () => {
+ const dispatch = jest.fn();
+ render();
+ fireEvent.change(screen.getByLabelText('Note lock password on this device'), { target: { value: 'local' } });
+ fireEvent.change(screen.getByLabelText('Note lock password on the sync target'), { target: { value: 'target' } });
+ return dispatch;
+};
+
+describe('NoteLockMigrationDialog/Dialog', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('should close once both passwords are accepted and leave the run to the banner', async () => {
+ startMock.mockImplementation(async (_local: string, _target: string, _dispatch: unknown, onStarted: ()=> void) => onStarted());
+ const dispatch = renderDialog();
+
+ fireEvent.click(screen.getByText('Migrate'));
+
+ await waitFor(() => expect(dispatch).toHaveBeenCalledWith({ type: 'DIALOG_CLOSE', name: 'noteLockMigration' }));
+ expect(startMock).toHaveBeenCalledWith('local', 'target', dispatch, expect.any(Function));
+ expect(skipMock).not.toHaveBeenCalled();
+ });
+
+ test('should report a wrong password and stay open', async () => {
+ startMock.mockRejectedValue(Object.assign(new Error('bad'), { name: 'OperationError' }));
+ const dispatch = renderDialog();
+
+ fireEvent.click(screen.getByText('Migrate'));
+
+ expect((await screen.findByRole('alert')).textContent).toContain('Invalid password');
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+
+ test.each([true, false])('should close only once the skip is confirmed (%s)', async (confirmed) => {
+ skipMock.mockResolvedValue(confirmed);
+ const dispatch = renderDialog();
+
+ fireEvent.click(screen.getByText('Skip'));
+
+ await waitFor(() => expect(skipMock).toHaveBeenCalledWith(dispatch));
+ if (confirmed) await waitFor(() => expect(dispatch).toHaveBeenCalledWith({ type: 'DIALOG_CLOSE', name: 'noteLockMigration' }));
+ else expect(dispatch).not.toHaveBeenCalled();
+ expect(startMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.tsx b/packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.tsx
new file mode 100644
index 0000000000..64d3105942
--- /dev/null
+++ b/packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.tsx
@@ -0,0 +1,91 @@
+import * as React from 'react';
+import { Dispatch } from 'redux';
+import { useCallback, useState } from 'react';
+import { _ } from '@joplin/lib/locale';
+import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
+import Dialog from '@joplin/lib/components/Dialog';
+import DialogTitle from '../DialogTitle';
+import LabelledPasswordInput from '../PasswordInput/LabelledPasswordInput';
+import { confirmAndSkipNoteLockKeyMigration, startNoteLockKeyMigration } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
+
+interface Props {
+ themeId: number;
+ dispatch: Dispatch;
+}
+
+export default function(props: Props) {
+ const [localPassword, setLocalPassword] = useState('');
+ const [targetPassword, setTargetPassword] = useState('');
+ const [migrating, setMigrating] = useState(false);
+ const [errorMessage, setErrorMessage] = useState('');
+
+ const onClose = useCallback(() => {
+ props.dispatch({
+ type: 'DIALOG_CLOSE',
+ name: 'noteLockMigration',
+ });
+ }, [props.dispatch]);
+
+ const onLocalPasswordChange = useCallback((event: React.ChangeEvent) => {
+ setLocalPassword(event.target.value);
+ }, []);
+
+ const onTargetPasswordChange = useCallback((event: React.ChangeEvent) => {
+ setTargetPassword(event.target.value);
+ }, []);
+
+ const onButtonRowClick = useCallback(async (event: ClickEvent) => {
+ if (event.buttonName === 'cancel') {
+ onClose();
+ return;
+ }
+
+ if (event.buttonName === 'skip') {
+ if (await confirmAndSkipNoteLockKeyMigration(props.dispatch)) onClose();
+ return;
+ }
+
+ if (event.buttonName === 'ok') {
+ setMigrating(true);
+ setErrorMessage('');
+ try {
+ await startNoteLockKeyMigration(localPassword, targetPassword, props.dispatch, onClose);
+ } catch (error) {
+ setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
+ setMigrating(false);
+ }
+ }
+ }, [localPassword, targetPassword, onClose, props.dispatch]);
+
+ return (
+
+ );
+}
diff --git a/packages/app-desktop/gui/WarningBanner.tsx b/packages/app-desktop/gui/WarningBanner.tsx
index d581304801..d3116d78d8 100644
--- a/packages/app-desktop/gui/WarningBanner.tsx
+++ b/packages/app-desktop/gui/WarningBanner.tsx
@@ -16,6 +16,9 @@ import { AppState } from '../app.reducer';
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
import { showMissingMasterKeyMessage, showUnknownKeyFormatBanner } from '@joplin/lib/services/e2ee/utils';
+import { noteLockKeyConflictFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
+import { NoteLockMigrationStatus } from '@joplin/lib/reducer';
+import { confirmAndSkipNoteLockKeyMigration, notMigratedMessage, withSkippedCount } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
import { connect } from 'react-redux';
@@ -34,6 +37,8 @@ interface Props {
hasDisabledSyncItems: boolean;
showUnknownKeyFormatMessage: boolean;
showMissingMasterKeyMessage: boolean;
+ showNoteLockKeyConflictMessage: boolean;
+ noteLockMigrationStatus: NoteLockMigrationStatus|null;
mustUpgradeAppMessage: string;
syncTargetAppMinVersion: string;
shouldSwitchToAppleSiliconVersion: boolean;
@@ -76,6 +81,20 @@ const WarningBanner: React.FC = props => {
});
};
+ const onMigrateLockedNotes = () => {
+ props.dispatch({
+ type: 'DIALOG_OPEN',
+ name: 'noteLockMigration',
+ });
+ };
+
+ const onDismissNoteLockMigrationStatus = () => {
+ props.dispatch({
+ type: 'NOTE_LOCK_MIGRATION_STATUS_SET',
+ value: null,
+ });
+ };
+
const onViewJoplinCloudLoginScreen = () => {
props.dispatch({
type: 'NAV_GO',
@@ -143,6 +162,28 @@ const WarningBanner: React.FC = props => {
_('Set the password'),
onViewSyncSettingsScreen,
);
+ } else if (props.noteLockMigrationStatus?.running) {
+ msg = renderNotificationMessage(_('Re-encrypting your locked notes with the synced key. Migration is in progress...'));
+ } else if (props.noteLockMigrationStatus?.failed || props.noteLockMigrationStatus?.awaitingDecryption) {
+ msg = renderNotificationMessage(
+ notMigratedMessage(props.noteLockMigrationStatus),
+ _('Retry'),
+ onMigrateLockedNotes,
+ _('Skip'),
+ () => void confirmAndSkipNoteLockKeyMigration(props.dispatch),
+ );
+ } else if (props.noteLockMigrationStatus) {
+ msg = renderNotificationMessage(
+ withSkippedCount(_('The migration of your locked notes has completed.'), props.noteLockMigrationStatus),
+ _('Dismiss'),
+ onDismissNoteLockMigrationStatus,
+ );
+ } else if (props.showNoteLockKeyConflictMessage) {
+ msg = renderNotificationMessage(
+ _('Synchronisation is stopped because the sync target uses a different note lock key to the one on your device.'),
+ _('Migrate your locked notes'),
+ onMigrateLockedNotes,
+ );
} else if (props.shouldUpgradeSyncTarget) {
msg = renderNotificationMessage(
_('The sync target needs to be upgraded before Joplin can sync. The operation may take a few minutes to complete and the app needs to be restarted. To proceed please click on the link.'),
@@ -291,6 +332,8 @@ const mapStateToProps = (state: AppState) => {
hasDisabledEncryptionItems: state.hasDisabledEncryptionItems,
showMissingMasterKeyMessage: showMissingMasterKeyMessage(syncInfo, state.notLoadedMasterKeys),
showUnknownKeyFormatMessage: showUnknownKeyFormatBanner(syncInfo),
+ showNoteLockKeyConflictMessage: noteLockKeyConflictFromState(state),
+ noteLockMigrationStatus: state.noteLockMigrationStatus,
showNeedUpgradingMasterKeyMessage: showNeedUpgradingEnabledMasterKeyMessage,
showShouldReencryptMessage: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES,
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.ts
index fcb44078f8..0140be3902 100644
--- a/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.ts
+++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.ts
@@ -65,6 +65,6 @@ export const runtime = (): CommandRuntime => {
bridge().showErrorMessageBox(error.message);
}
},
- enabledCondition: 'oneNoteSelected && !noteIsLocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder',
+ enabledCondition: 'oneNoteSelected && !noteIsLocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder && !noteLockMigrationRunning',
};
};
diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.tsx b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.tsx
index 94a3f27d9b..9b55f33756 100644
--- a/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.tsx
+++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.tsx
@@ -4,6 +4,7 @@ import { Dispatch } from 'redux';
import SyncWizardDialog from '../../SyncWizard/Dialog';
import MasterPasswordDialog from '../../MasterPasswordDialog/Dialog';
import NoteLockUnlockDialog from '../../NoteLockUnlockDialog/Dialog';
+import NoteLockMigrationDialog from '../../NoteLockMigrationDialog/Dialog';
import EditFolderDialog from '../../EditFolderDialog/Dialog';
import PdfViewer from '../../PdfViewer';
@@ -40,6 +41,13 @@ const appDialogs: Record = {
},
},
+ noteLockMigration: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
+ render: (props: RegisteredDialogProps, customProps: any) => {
+ return ;
+ },
+ },
+
editFolder: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
render: (props: RegisteredDialogProps, customProps: any) => {
diff --git a/packages/app-desktop/main.scss b/packages/app-desktop/main.scss
index 869c17ca04..a40b91b807 100644
--- a/packages/app-desktop/main.scss
+++ b/packages/app-desktop/main.scss
@@ -319,7 +319,13 @@ Component-specific classes
max-width: 600px;
}
-.master-password-dialog .dialog-content, .enable-encryption-dialog .dialog-content, .disable-encryption-dialog .dialog-content {
+.note-lock-migration-dialog > .content {
+ box-sizing: border-box;
+ width: 500px;
+ max-width: 100%;
+}
+
+.master-password-dialog .dialog-content, .enable-encryption-dialog .dialog-content, .disable-encryption-dialog .dialog-content, .note-lock-migration-dialog .dialog-content {
background-color: var(--joplin-background-color3);
padding: 1em;
padding-bottom: 1px;
diff --git a/packages/app-mobile/components/ScreenHeader/WarningBanner.test.tsx b/packages/app-mobile/components/ScreenHeader/WarningBanner.test.tsx
index 296534b46b..d0fc42ce92 100644
--- a/packages/app-mobile/components/ScreenHeader/WarningBanner.test.tsx
+++ b/packages/app-mobile/components/ScreenHeader/WarningBanner.test.tsx
@@ -11,6 +11,7 @@ import { Store } from 'redux';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
import MasterKey from '@joplin/lib/models/MasterKey';
+import shim from '@joplin/lib/shim';
interface WrapperProps {
store: Store;
@@ -136,6 +137,47 @@ describe('WarningBanner', () => {
expect(screen.getByText(query)).toBeVisible();
});
+ test('the note lock key conflict banner should link to the migration screen', () => {
+ Setting.setValue('featureFlag.noteLock', true);
+ Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: { id: 'sync-target-key' }, syncMigrationId: 'lineage' });
+ const mock = createMockStore();
+
+ render();
+ const banner = screen.getByText(/different note lock key/);
+ expect(banner).toBeVisible();
+ fireEvent.press(banner);
+
+ expect(mock.getRouteName()).toBe('NoteLockMigration');
+ });
+
+ test('the note lock migration banner should follow the run and be dismissed once it has completed', () => {
+ Setting.setValue('featureFlag.noteLock', true);
+ Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: { id: 'sync-target-key' }, syncMigrationId: 'lineage' });
+ const mock = createMockStore();
+ const setStatus = (value: unknown) => act(() => {
+ mock.store.dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value });
+ });
+ render();
+
+ setStatus({ running: true, failed: 0, skipped: 0 });
+ expect(screen.getByText(/Migration is in progress/)).toBeVisible();
+ expect(screen.queryByText(/different note lock key/)).toBeNull();
+
+ setStatus({ running: false, failed: 2, skipped: 0 });
+ expect(screen.getByText(/2 locked notes could not be migrated/)).toBeVisible();
+ const showMessageBox = jest.spyOn(shim, 'showMessageBox').mockResolvedValue(1);
+ fireEvent.press(screen.getByText('Skip'));
+ expect(showMessageBox).toHaveBeenCalled();
+ showMessageBox.mockRestore();
+ fireEvent.press(screen.getByText('Retry'));
+ expect(mock.getRouteName()).toBe('NoteLockMigration');
+
+ setStatus({ running: false, failed: 0, skipped: 1 });
+ expect(screen.getByText(/has completed.*1 locked note was skipped/)).toBeVisible();
+ fireEvent.press(screen.getByText(/has completed/));
+ expect(mock.store.getState().noteLockMigrationStatus).toBeNull();
+ });
+
test('invalid credentials banner for Joplin Cloud should link to a login screen', () => {
Setting.setValue('sync.target', 10);
const mock = createMockStore();
diff --git a/packages/app-mobile/components/ScreenHeader/WarningBanner.tsx b/packages/app-mobile/components/ScreenHeader/WarningBanner.tsx
index c2a4947273..ef6d042d22 100644
--- a/packages/app-mobile/components/ScreenHeader/WarningBanner.tsx
+++ b/packages/app-mobile/components/ScreenHeader/WarningBanner.tsx
@@ -3,10 +3,14 @@ import { useState } from 'react';
import { connect } from 'react-redux';
import { Platform } from 'react-native';
import { AppState } from '../../utils/types';
-import WarningBox, { WarningBoxTarget } from './WarningBox';
+import WarningBox, { WarningBoxAction, WarningBoxTarget } from './WarningBox';
+import NavService from '@joplin/lib/services/NavService';
import { _ } from '@joplin/lib/locale';
import { showMissingMasterKeyMessage, showUnknownKeyFormatBanner } from '@joplin/lib/services/e2ee/utils';
-import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
+import { localSyncInfoFromState, noteLockKeyConflictFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
+import { NoteLockMigrationStatus } from '@joplin/lib/reducer';
+import { confirmAndSkipNoteLockKeyMigration, notMigratedMessage, withSkippedCount } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
+import { Dispatch } from 'redux';
import Setting from '@joplin/lib/models/Setting';
import { ShareInvitation, ShareUserStatus } from '@joplin/lib/services/share/reducer';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
@@ -30,6 +34,10 @@ interface Props {
shareInvitations: ShareInvitation[];
processingShareInvitationResponse: boolean;
showInvalidJoplinCloudCredential: boolean;
+ noteLockKeyConflict: boolean;
+ showNoteLockKeyConflictMessage: boolean|undefined;
+ noteLockMigrationStatus: NoteLockMigrationStatus|null;
+ dispatch: Dispatch;
}
const androidGooglePlayUrl = 'https://play.google.com/store/apps/details?id=net.cozic.joplin';
@@ -60,13 +68,14 @@ const WarningBannerComponent: React.FC = props => {
}
}, [props.mustUpgradeAppMessage, props.syncTargetAppMinVersion]);
- const renderWarningBox = (key: string, message: string, target: WarningBoxTarget) => {
+ const renderWarningBox = (key: string, message: string, target: WarningBoxTarget, actions?: WarningBoxAction[]) => {
return ;
};
@@ -128,6 +137,22 @@ const WarningBannerComponent: React.FC = props => {
if (props.hasDisabledEncryptionItems) {
warningComps.push(renderWarningBox('cannotDecrypt', _('Some items cannot be decrypted.'), { screen: 'Status' }));
}
+ const migration = props.noteLockMigrationStatus;
+ if (props.showNoteLockKeyConflictMessage !== false) {
+ if (migration?.running) {
+ warningComps.push(renderWarningBox('noteLockMigration', _('Re-encrypting your locked notes with the synced key. Migration is in progress...'), null));
+ } else if (migration?.failed || migration?.awaitingDecryption) {
+ warningComps.push(renderWarningBox('noteLockMigration', notMigratedMessage(migration), null, [
+ { title: _('Retry'), onPress: () => void NavService.go('NoteLockMigration') },
+ { title: _('Skip'), onPress: () => void confirmAndSkipNoteLockKeyMigration(props.dispatch) },
+ ]));
+ } else if (migration) {
+ const onDismiss = () => props.dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: null });
+ warningComps.push(renderWarningBox('noteLockMigration', `${withSkippedCount(_('The migration of your locked notes has completed.'), migration)} ${_('Press to dismiss.')}`, { onPress: onDismiss }));
+ } else if (props.noteLockKeyConflict) {
+ warningComps.push(renderWarningBox('noteLockMigration', _('The sync target uses a different note lock key to the one on your device. Press to migrate your locked notes.'), { screen: 'NoteLockMigration' }));
+ }
+ }
if (props.showInvalidJoplinCloudCredential) {
const target = { screen: 'JoplinCloudLogin' };
warningComps.push(renderWarningBox('syncLogin', _('Your Joplin Cloud credentials are invalid, please login.'), target));
@@ -185,5 +210,7 @@ export default connect((state: AppState) => {
shareInvitations: state.shareService.shareInvitations,
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && !isSyncLoginRoute(state) && state.mustAuthenticate,
+ noteLockKeyConflict: noteLockKeyConflictFromState(state),
+ noteLockMigrationStatus: state.noteLockMigrationStatus,
};
})(WarningBannerComponent);
diff --git a/packages/app-mobile/components/ScreenHeader/WarningBox.tsx b/packages/app-mobile/components/ScreenHeader/WarningBox.tsx
index f9bbd1037c..7dc3f5eb58 100644
--- a/packages/app-mobile/components/ScreenHeader/WarningBox.tsx
+++ b/packages/app-mobile/components/ScreenHeader/WarningBox.tsx
@@ -17,13 +17,26 @@ interface ScreenTarget {
url?: undefined;
}
-export type WarningBoxTarget = UrlTarget|ScreenTarget;
+interface CallbackTarget {
+ onPress: ()=> void;
+
+ url?: undefined;
+ screen?: undefined;
+}
+
+export type WarningBoxTarget = UrlTarget|ScreenTarget|CallbackTarget;
+
+export interface WarningBoxAction {
+ title: string;
+ onPress: ()=> void;
+}
interface Props {
themeId: number;
target: WarningBoxTarget;
message: string;
testID?: string;
+ actions?: WarningBoxAction[];
}
const useStyles = (themeId: number) => {
@@ -39,6 +52,12 @@ const useStyles = (themeId: number) => {
flex: 1,
color: 'black',
},
+ actionText: {
+ color: 'black',
+ fontWeight: 'bold',
+ textDecorationLine: 'underline',
+ marginLeft: theme.marginLeft,
+ },
});
}, [themeId]);
};
@@ -56,6 +75,8 @@ const WarningBox: React.FC = props => {
const isUrlTarget = (target: WarningBoxTarget): target is UrlTarget => !!target.url;
if (isUrlTarget(target)) {
void Linking.openURL(target.url);
+ } else if ('onPress' in target) {
+ target.onPress();
} else {
void NavService.go(target.screen, target.screenProps);
}
@@ -63,6 +84,18 @@ const WarningBox: React.FC = props => {
const hasTarget = !!propsRef.current.target;
const bannerContent = {props.message};
+ if (props.actions) {
+ return (
+
+ {bannerContent}
+ {props.actions.map(action => (
+
+ {action.title}
+
+ ))}
+
+ );
+ }
if (hasTarget) {
return (
);
diff --git a/packages/app-mobile/components/screens/Note/Note.tsx b/packages/app-mobile/components/screens/Note/Note.tsx
index 0504a2e768..b3b3ca6563 100644
--- a/packages/app-mobile/components/screens/Note/Note.tsx
+++ b/packages/app-mobile/components/screens/Note/Note.tsx
@@ -39,6 +39,7 @@ import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import NoteLockKey, { DecryptedNoteLockKey } from '@joplin/lib/services/noteLock/NoteLockKey';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
import { disableNoteLock, enableNoteLock } from '@joplin/lib/services/noteLock/setNoteLockState';
+import { ErrorCode } from '@joplin/lib/errors';
import eventManager, { EventName, NoteLockNoteStateChangeEvent } from '@joplin/lib/eventManager';
import NoteLockPanel from './NoteLockPanel';
import DismissibleDialog, { DialogVariant } from '../../DismissibleDialog';
@@ -135,6 +136,7 @@ interface Props extends BaseProps {
canPublish: boolean;
noteVisiblePanes: string[];
noteLockSessionUnlocked: boolean;
+ noteLockMigrationRunning: boolean;
}
interface ComponentProps extends Props {
@@ -974,7 +976,7 @@ class NoteScreenComponent extends BaseScreenComponent imp
}
} catch (error) {
reg.logger().warn('Could not change the note lock state:', error);
- await this.props.dialogs.error(_('Could not update the note lock. Please try again.'));
+ await this.props.dialogs.error(error.code === ErrorCode.NoteLockKeyConflict ? error.message : _('Could not update the note lock. Please try again.'));
}
}
@@ -1459,7 +1461,7 @@ class NoteScreenComponent extends BaseScreenComponent imp
const pluginCommands = pluginUtils.commandNamesFromViews(this.props.plugins, 'noteToolbar');
- const cacheKey = md5([isTodo, isSaved, pluginCommands.join(','), readOnly, this.state.mode, isCodeView, note.is_locked, this.props.noteLockSessionUnlocked, !!this.state.noteLockKey, this.state.noteLockUndecryptable].join('_'));
+ const cacheKey = md5([isTodo, isSaved, pluginCommands.join(','), readOnly, this.state.mode, isCodeView, note.is_locked, this.props.noteLockSessionUnlocked, !!this.state.noteLockKey, this.state.noteLockUndecryptable, this.props.noteLockMigrationRunning].join('_'));
if (!this.menuOptionsCache_) this.menuOptionsCache_ = {};
if (this.menuOptionsCache_[cacheKey]) return this.menuOptionsCache_[cacheKey];
@@ -1591,7 +1593,7 @@ class NoteScreenComponent extends BaseScreenComponent imp
onPress: () => {
this.enableNoteEncryption_onPress();
},
- disabled: readOnly,
+ disabled: readOnly || this.props.noteLockMigrationRunning,
});
} else {
output.push({
@@ -1923,6 +1925,7 @@ class NoteScreenComponent extends BaseScreenComponent imp
themeId={this.props.themeId}
hasNoteLockKey={!!NoteLockKey.instance().load()}
undecryptable={this.state.noteLockUndecryptable}
+ migrationRunning={this.props.noteLockMigrationRunning}
/>;
} else if (editorView) {
bodyComponent = renderPluginEditor();
@@ -2238,6 +2241,7 @@ const NoteScreen = connect((state: AppState) => {
useEditorBeta: !state.settings['editor.usePlainText'],
canPublish: whenClause.joplinServerConnected && !whenClause.inTrash,
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
+ noteLockMigrationRunning: !!state.noteLockMigrationStatus?.running,
};
})(NoteScreenWrapper);
diff --git a/packages/app-mobile/components/screens/Note/NoteLockPanel.tsx b/packages/app-mobile/components/screens/Note/NoteLockPanel.tsx
index 10efd2d578..9c78fc97f9 100644
--- a/packages/app-mobile/components/screens/Note/NoteLockPanel.tsx
+++ b/packages/app-mobile/components/screens/Note/NoteLockPanel.tsx
@@ -15,6 +15,7 @@ interface Props {
themeId: number;
hasNoteLockKey: boolean;
undecryptable?: boolean;
+ migrationRunning?: boolean;
onUnlocked?: ()=> void;
}
@@ -94,6 +95,10 @@ const NoteLockPanel = (props: Props) => {
}, [password, unlocking, props.onUnlocked]);
const renderForm = () => {
+ if (props.migrationRunning) {
+ return {_('This note cannot be read while a note lock key migration is in progress.')};
+ }
+
if (props.undecryptable) {
return (
{_('This note could not be unlocked. If it was locked prior to a password reset, the content is no longer recoverable.')}
diff --git a/packages/app-mobile/components/screens/NoteLockMigrationScreen.test.tsx b/packages/app-mobile/components/screens/NoteLockMigrationScreen.test.tsx
new file mode 100644
index 0000000000..a1f369c2aa
--- /dev/null
+++ b/packages/app-mobile/components/screens/NoteLockMigrationScreen.test.tsx
@@ -0,0 +1,100 @@
+import * as React from 'react';
+import { Store } from 'redux';
+import Setting from '@joplin/lib/models/Setting';
+import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
+import { confirmAndSkipNoteLockKeyMigration, startNoteLockKeyMigration } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
+import { NoteLockMigrationScreenComponent } from './NoteLockMigrationScreen';
+import TestProviderStack from '../testing/TestProviderStack';
+import createMockReduxStore from '../../utils/testing/createMockReduxStore';
+import setupGlobalStore from '../../utils/testing/setupGlobalStore';
+import { AppState } from '../../utils/types';
+import { fireEvent, render, screen, waitFor } from '../../utils/testing/testingLibrary';
+import BackButtonService from '../../services/BackButtonService';
+
+jest.mock('@joplin/lib/services/noteLock/NoteLockKeyMigration', () => ({
+ startNoteLockKeyMigration: jest.fn(),
+ confirmAndSkipNoteLockKeyMigration: jest.fn(),
+}));
+
+const startMock = startNoteLockKeyMigration as jest.Mock;
+const skipMock = confirmAndSkipNoteLockKeyMigration as jest.Mock;
+
+let store: Store;
+const renderScreen = () => {
+ const dispatch = jest.fn();
+ render(
+
+
+ ,
+ );
+ fireEvent.changeText(screen.getByLabelText('Note lock password on this device'), 'local');
+ fireEvent.changeText(screen.getByLabelText('Note lock password on the sync target'), 'target');
+ return dispatch;
+};
+
+describe('NoteLockMigrationScreen', () => {
+ beforeEach(async () => {
+ await setupDatabaseAndSynchronizer(0);
+ await switchClient(0);
+ store = createMockReduxStore();
+ setupGlobalStore(store);
+ jest.clearAllMocks();
+ });
+ afterEach(() => {
+ screen.unmount();
+ });
+
+ test('should go back once both passwords are accepted and leave the run to the banner', async () => {
+ startMock.mockImplementation(async (_local: string, _target: string, _dispatch: unknown, onStarted: ()=> void) => onStarted());
+ const dispatch = renderScreen();
+
+ fireEvent.press(screen.getByText('Migrate'));
+
+ await waitFor(() => expect(dispatch).toHaveBeenCalledWith({ type: 'NAV_BACK' }));
+ expect(startMock).toHaveBeenCalledWith('local', 'target', dispatch, expect.any(Function));
+ expect(skipMock).not.toHaveBeenCalled();
+ });
+
+ test('should block going back while the passwords are checked', async () => {
+ startMock.mockReturnValue(new Promise(() => {}));
+ renderScreen();
+ expect(screen.getByLabelText('Back')).toBeVisible();
+
+ fireEvent.press(screen.getByText('Migrate'));
+
+ await waitFor(() => expect(screen.queryByLabelText('Back')).toBeNull());
+ expect(await BackButtonService.back()).toBe(true);
+ });
+
+ test('should not show the migration banner on its own screen', async () => {
+ Setting.setValue('featureFlag.noteLock', true);
+ Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: { id: 'target-key' }, syncMigrationId: 'lineage' });
+ store = createMockReduxStore();
+ setupGlobalStore(store);
+ renderScreen();
+
+ expect(screen.queryByText(/Press to migrate/)).toBeNull();
+ });
+
+ test('should report a wrong password and stay', async () => {
+ startMock.mockRejectedValue(Object.assign(new Error('bad'), { name: 'OperationError' }));
+ const dispatch = renderScreen();
+
+ fireEvent.press(screen.getByText('Migrate'));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Invalid password');
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+
+ test.each([true, false])('should leave the screen only once the skip is confirmed (%s)', async (confirmed) => {
+ skipMock.mockResolvedValue(confirmed);
+ const dispatch = renderScreen();
+
+ fireEvent.press(screen.getByText('Skip'));
+
+ await waitFor(() => expect(skipMock).toHaveBeenCalledWith(dispatch));
+ if (confirmed) await waitFor(() => expect(dispatch).toHaveBeenCalledWith({ type: 'NAV_BACK' }));
+ else expect(dispatch).not.toHaveBeenCalled();
+ expect(startMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/app-mobile/components/screens/NoteLockMigrationScreen.tsx b/packages/app-mobile/components/screens/NoteLockMigrationScreen.tsx
new file mode 100644
index 0000000000..e1c74c7753
--- /dev/null
+++ b/packages/app-mobile/components/screens/NoteLockMigrationScreen.tsx
@@ -0,0 +1,132 @@
+import * as React from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { View, Text, TextInput, StyleSheet } from 'react-native';
+import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
+import { themeStyle } from '../global-style';
+import { _ } from '@joplin/lib/locale';
+import { confirmAndSkipNoteLockKeyMigration, startNoteLockKeyMigration } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
+import ScreenHeader from '../ScreenHeader';
+import { PrimaryButton, SecondaryButton } from '../buttons';
+import { AppState } from '../../utils/types';
+import BackButtonService from '../../services/BackButtonService';
+
+interface Props {
+ themeId: number;
+ dispatch: Dispatch;
+}
+
+export const NoteLockMigrationScreenComponent: React.FC = props => {
+ const [localPassword, setLocalPassword] = useState('');
+ const [targetPassword, setTargetPassword] = useState('');
+ const [migrating, setMigrating] = useState(false);
+ const [errorMessage, setErrorMessage] = useState('');
+
+ const theme = useMemo(() => themeStyle(props.themeId), [props.themeId]);
+
+ const styles = useMemo(() => {
+ return StyleSheet.create({
+ root: theme.rootStyle,
+ container: {
+ padding: theme.margin,
+ },
+ normalText: {
+ fontSize: theme.fontSize,
+ color: theme.color,
+ marginBottom: 5,
+ },
+ // Same input style as ConfigScreen/NoteLockConfig.tsx
+ passwordInput: {
+ marginTop: 5,
+ marginBottom: theme.itemMarginBottom,
+ color: theme.color,
+ borderWidth: 1,
+ borderColor: theme.dividerColor,
+ borderRadius: 3,
+ padding: 10,
+ },
+ errorText: {
+ fontSize: theme.fontSize,
+ color: theme.colorError,
+ marginBottom: theme.itemMarginBottom,
+ },
+ buttonContainer: {
+ marginTop: theme.itemMarginTop,
+ gap: theme.margin,
+ },
+ });
+ }, [theme]);
+
+ // Leaving while the passwords are checked would let the run start and navigate from under whatever screen replaced this one.
+ useEffect(() => {
+ if (!migrating) return () => {};
+ const handler = () => true;
+ BackButtonService.addHandler(handler);
+ return () => BackButtonService.removeHandler(handler);
+ }, [migrating]);
+
+ // Duplicates the migrate and skip handlers in app-desktop/gui/NoteLockMigrationDialog/Dialog.tsx.
+ const onMigrate = useCallback(async () => {
+ setMigrating(true);
+ setErrorMessage('');
+ try {
+ await startNoteLockKeyMigration(localPassword, targetPassword, props.dispatch, () => props.dispatch({ type: 'NAV_BACK' }));
+ } catch (error) {
+ setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
+ setMigrating(false);
+ }
+ }, [localPassword, targetPassword, props.dispatch]);
+
+ const onSkip = useCallback(async () => {
+ if (await confirmAndSkipNoteLockKeyMigration(props.dispatch)) props.dispatch({ type: 'NAV_BACK' });
+ }, [props.dispatch]);
+
+ const localLabelId = 'note-lock-migration-local-password';
+ const targetLabelId = 'note-lock-migration-target-password';
+
+ return (
+
+
+
+ {_('The sync target uses a different note lock key to the one on your device. Your locked notes must be re-encrypted with the synced key before synchronisation can continue. Locked notes cannot be read while they are being migrated.')}
+ {_('Note lock password on this device')}
+
+ {_('Note lock password on the sync target')}
+
+ {!!errorMessage && {errorMessage}}
+
+ {_('Migrate')}
+ {_('Skip')}
+
+
+
+ );
+};
+
+export default connect((state: AppState) => {
+ return {
+ themeId: state.settings.theme,
+ };
+})(NoteLockMigrationScreenComponent);
diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx
index 017f2f5f70..1bd3d665d6 100644
--- a/packages/app-mobile/root.tsx
+++ b/packages/app-mobile/root.tsx
@@ -9,6 +9,7 @@ import time from '@joplin/lib/time';
import Logger from '@joplin/utils/Logger';
import NoteScreen from './components/screens/Note/Note';
import UpgradeSyncTargetScreen from './components/screens/UpgradeSyncTargetScreen';
+import NoteLockMigrationScreen from './components/screens/NoteLockMigrationScreen';
import Setting, { } from '@joplin/lib/models/Setting';
import PoorManIntervals from '@joplin/lib/PoorManIntervals';
import { NotesParent, serializeNotesParent } from '@joplin/lib/reducer';
@@ -776,6 +777,7 @@ class AppComponent extends React.Component
JoplinServerSamlLogin: { screen: SsoLoginScreen(new SamlShared()) },
EncryptionConfig: { screen: EncryptionConfigScreen },
UpgradeSyncTarget: { screen: UpgradeSyncTargetScreen },
+ NoteLockMigration: { screen: NoteLockMigrationScreen },
ShareManager: { screen: ShareManager },
ProfileSwitcher: { screen: ProfileSwitcher },
ProfileEditor: { screen: ProfileEditor },
diff --git a/packages/lib/Synchronizer.ts b/packages/lib/Synchronizer.ts
index bc147d1822..1d14333390 100644
--- a/packages/lib/Synchronizer.ts
+++ b/packages/lib/Synchronizer.ts
@@ -25,7 +25,9 @@ import TaskQueue from './TaskQueue';
import ItemUploader from './services/synchronizer/ItemUploader';
import { FileApi, getSupportsDeltaWithItems, isLocalServer, PaginatedList, RemoteItem, enableEnhancedBasicDeltaAlgorithm } from './file-api';
import JoplinDatabase from './JoplinDatabase';
-import { checkIfCanSync, fetchSyncInfo, checkSyncTargetIsValid, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
+import { parkedNoteLockKeyInUse } from './services/noteLock/NoteLockKeyMigration';
+import NoteLockSession from './services/noteLock/NoteLockSession';
+import { checkIfCanSync, fetchSyncInfo, checkSyncTargetIsValid, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, uploadSyncInfo, checkNoteLockKeyConflict, checkNoteLockKeyMigrationId, checkNoteLockKeyUnchanged, checkRemoteNoteLockKeyUnchanged, noteLockKeyConflict } from './services/synchronizer/syncInfoUtils';
import { getMasterPassword, setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
import { generateKeyPair } from './services/e2ee/ppk/ppk';
import syncDebugLog from './services/synchronizer/syncDebugLog';
@@ -494,6 +496,11 @@ export default class Synchronizer {
this.api().setTempDirName(Dirnames.Temp);
try {
+ // Checked before anything is uploaded so a key without an id never reaches the target. The CLI cannot create or reset
+ // a note lock key or run a migration: it adopts the target's key, and a migration it would need (its locked notes
+ // meeting a different key, for example after switching the sync target) stops its sync. Revisit if it gains these.
+ checkNoteLockKeyMigrationId(localSyncInfo(), false);
+
let remoteInfo = await fetchSyncInfo(this.api());
logger.info('Sync target remote info:', remoteInfo.filterSyncInfo());
eventManager.emit(EventName.SessionEstablished);
@@ -514,7 +521,10 @@ export default class Synchronizer {
const appVersion = shim.appVersion();
if (appVersion !== 'unknown') checkIfCanSync(remoteInfo, appVersion);
- let localInfo = await localSyncInfo();
+ let localInfo = localSyncInfo();
+ // Read in the same tick as the key, so a reset landing mid-sync is seen whole or not at all.
+ const noteLockPasswordReset = Setting.value('noteLock.passwordReset');
+ const noteLockKeyIdToReset = Setting.value('noteLock.keyIdToReset');
logger.info('Sync target local info:', localInfo.filterSyncInfo());
localInfo = await this.setPpkIfNotExist(localInfo, remoteInfo);
@@ -523,18 +533,50 @@ export default class Synchronizer {
localInfo = setMasterKeyHasBeenUsed(localInfo, localInfo.activeMasterKeyId);
}
+ checkNoteLockKeyMigrationId(remoteInfo, true);
+ const hasLockedNotes = await Note.hasLockedNotes();
+ // A reset only overwrites the target key it replaced, so a target reset elsewhere since then wins.
+ const resetPropagates = noteLockPasswordReset && (!remoteInfo.noteLockKey || remoteInfo.noteLockKey.id === noteLockKeyIdToReset);
+ const staleReset = noteLockPasswordReset && !resetPropagates;
+ const notesUseParkedKey = await parkedNoteLockKeyInUse();
+ const hadParkedKey = !!noteLockKeyConflict();
+ // Read again after the await: a migration may have finished meanwhile. A running one is checked in the same tick
+ // as the conflict, so it cannot start in between.
+ checkNoteLockKeyConflict(localSyncInfo(), remoteInfo, hasLockedNotes, staleReset, notesUseParkedKey || NoteLockSession.instance().isMigrating());
+ // The target no longer conflicts, so a banner left by an earlier run with nothing moved has nothing to retry.
+ if (hadParkedKey) this.dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: null });
+
// console.info('LOCAL', localInfo);
// console.info('REMOTE', remoteInfo);
+ let reconciledNoteLockKeyId = localInfo.noteLockKey?.id;
if (!syncInfoEquals(localInfo, remoteInfo)) {
- let newInfo = mergeSyncInfos(localInfo, remoteInfo);
+ // The merge works from localInfo, which a key change since it was read would make stale.
+ checkNoteLockKeyUnchanged(localInfo);
+ let newInfo = mergeSyncInfos(localInfo, remoteInfo, { resetPropagates, hasLocalLockedNotes: hasLockedNotes });
if (newInfo.activeMasterKeyId) newInfo = setMasterKeyHasBeenUsed(newInfo, newInfo.activeMasterKeyId);
const previousE2EE = localInfo.e2ee;
logger.info('Sync target info differs between local and remote - merging infos: ', newInfo.toObject());
+ // The checks above let the target key replace the local one, so it is adopted before the upload: a note locked
+ // from now on is encrypted with the key the target holds.
+ if (localInfo.noteLockKey && newInfo.noteLockKey?.id !== localInfo.noteLockKey.id) {
+ const adoptedInfo = localSyncInfo();
+ adoptedInfo.noteLockKey = localInfo.noteLockKey = newInfo.noteLockKey;
+ adoptedInfo.syncMigrationId = localInfo.syncMigrationId = newInfo.syncMigrationId;
+ saveLocalSyncInfo(adoptedInfo);
+ }
+
await this.lockHandler().acquireLock(LockType.Exclusive, this.lockClientType(), this.clientId_, { clearExistingSyncLocksFromTheSameClient: true });
+ // Must stay immediately before the upload. Sync locks are disabled (the lock above does nothing), so another device can
+ // upload its own info.json after this sync fetched the target's, for example a password reset, which the upload below
+ // would revert to the old key.
+ checkRemoteNoteLockKeyUnchanged(remoteInfo, await fetchSyncInfo(this.api()));
await uploadSyncInfo(this.api(), newInfo);
+ // A reset or password change on this device during the upload would be overwritten by the save below.
+ checkNoteLockKeyUnchanged(localInfo);
await saveLocalSyncInfo(newInfo);
+ reconciledNoteLockKeyId = newInfo.noteLockKey?.id;
await this.lockHandler().releaseLock(LockType.Exclusive, this.lockClientType(), this.clientId_);
// console.info('NEW', newInfo);
@@ -562,6 +604,20 @@ export default class Synchronizer {
// Set it to remote anyway so that timestamps are the same
// Note: that's probably not needed anymore?
// await uploadSyncInfo(this.api(), remoteInfo);
+
+ // A reset that landed mid-sync keeps its flag for the next sync.
+ if (noteLockPasswordReset) checkNoteLockKeyUnchanged(localInfo);
+ }
+
+ // Either the reset key is on the target now or a different lineage replaced it, so the flag is spent. A reset
+ // that landed after the local save replaces the key the target holds now, so it keeps its flag.
+ if (noteLockPasswordReset) {
+ if (localSyncInfo().noteLockKey?.id === reconciledNoteLockKeyId) {
+ Setting.setValue('noteLock.passwordReset', false);
+ Setting.setValue('noteLock.keyIdToReset', '');
+ } else {
+ Setting.setValue('noteLock.keyIdToReset', reconciledNoteLockKeyId ?? '');
+ }
}
} catch (error) {
if (error.code === 403) {
diff --git a/packages/lib/errors.ts b/packages/lib/errors.ts
index 514d6468ac..d24c5f2b2d 100644
--- a/packages/lib/errors.ts
+++ b/packages/lib/errors.ts
@@ -6,4 +6,5 @@ export enum ErrorCode {
UnsupportedMimeType = 'unsupportedMimeType',
MustUpgradeApp = 'mustUpgradeApp',
DownloadLimiter = 'downloadLimiter',
+ NoteLockKeyConflict = 'noteLockKeyConflict',
}
diff --git a/packages/lib/models/Note.ts b/packages/lib/models/Note.ts
index 32a8bda33b..939a63f675 100644
--- a/packages/lib/models/Note.ts
+++ b/packages/lib/models/Note.ts
@@ -608,6 +608,16 @@ export default class Note extends BaseItem {
return r && r.total ? r.total : 0;
}
+ // Trashed and conflict notes count too: their content still depends on the note lock key.
+ public static async hasLockedNotes() {
+ return !!(await this.db().selectOne('SELECT 1 FROM notes WHERE is_locked = 1 LIMIT 1'));
+ }
+
+ public static async lockedNoteIds() {
+ const rows = await this.db().selectAll<{ id: string }>('SELECT id FROM notes WHERE is_locked = 1');
+ return rows.map(row => row.id);
+ }
+
public static unconflictedNotes() {
return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0');
}
diff --git a/packages/lib/models/settings/builtInMetadata.ts b/packages/lib/models/settings/builtInMetadata.ts
index 46a3f192df..340666b13a 100644
--- a/packages/lib/models/settings/builtInMetadata.ts
+++ b/packages/lib/models/settings/builtInMetadata.ts
@@ -8,6 +8,7 @@ import { AppType, SettingItemSubType, SettingItemType, SettingStorage, SyncStart
import { defaultListColumns } from '../../services/plugins/api/noteListType';
import type { PluginSettings } from '../../services/plugins/PluginService';
import type { PublicPrivateKeyPair } from '../../services/e2ee/ppk/ppk';
+import type { NoteLockKeyConflict } from '../../services/synchronizer/syncInfoUtils';
import { EmptyObject } from '@joplin/utils/types';
const ObjectUtils = require('../../ObjectUtils');
import { toTitleCase } from '../../string-utils';
@@ -2357,6 +2358,24 @@ const builtInMetadata = (Setting: typeof SettingType) => {
storage: SettingStorage.File,
},
+ 'noteLock.passwordReset': {
+ value: false,
+ type: SettingItemType.Bool,
+ public: false,
+ },
+
+ 'noteLock.keyIdToReset': {
+ value: '',
+ type: SettingItemType.String,
+ public: false,
+ },
+
+ 'noteLock.conflictNoteLockKey': {
+ value: {} as Partial,
+ type: SettingItemType.Object,
+ public: false,
+ },
+
'featureFlag.autoUpdaterServiceEnabled': {
value: false,
type: SettingItemType.Bool,
diff --git a/packages/lib/reducer.ts b/packages/lib/reducer.ts
index 516467b007..0d21d72c7a 100644
--- a/packages/lib/reducer.ts
+++ b/packages/lib/reducer.ts
@@ -90,6 +90,14 @@ export interface StateLastDeletion {
timestamp: number;
}
+// The background note lock key migration, kept after it ends until its banner is dismissed.
+export interface NoteLockMigrationStatus {
+ running: boolean;
+ failed: number;
+ skipped: number;
+ awaitingDecryption: number;
+}
+
export type HighlightedWord = ComplexTerm|string;
export interface WindowState {
@@ -191,6 +199,7 @@ export interface State extends WindowState {
isInsertingNotes: boolean;
hasEncryptedItems: boolean;
noteLockSessionUnlocked: boolean;
+ noteLockMigrationStatus: NoteLockMigrationStatus|null;
needApiAuth: boolean;
profileConfig: ProfileConfig;
noteListRendererIds: string[];
@@ -262,6 +271,7 @@ export const defaultState: State = {
isInsertingNotes: false,
hasEncryptedItems: false,
noteLockSessionUnlocked: false,
+ noteLockMigrationStatus: null,
needApiAuth: false,
profileConfig: null,
noteListRendererIds: getListRendererIds(),
@@ -1579,6 +1589,10 @@ const reducer = produce((draft: Draft = defaultState, action: any) => {
draft.noteLockSessionUnlocked = action.value;
break;
+ case 'NOTE_LOCK_MIGRATION_STATUS_SET':
+ draft.noteLockMigrationStatus = action.value;
+ break;
+
case 'SET_ACTIVE_NOTE_IS_UNDECRYPTABLE':
{
// The editor reporting this may be in a background window, so the flag follows that
diff --git a/packages/lib/services/commands/stateToWhenClauseContext.ts b/packages/lib/services/commands/stateToWhenClauseContext.ts
index 75de868a6d..e101f2f28a 100644
--- a/packages/lib/services/commands/stateToWhenClauseContext.ts
+++ b/packages/lib/services/commands/stateToWhenClauseContext.ts
@@ -45,6 +45,7 @@ export interface WhenClauseContext {
noteIsLocked: boolean;
noteLockContentUnavailable: boolean;
noteLockSessionUnlocked: boolean;
+ noteLockMigrationRunning: boolean;
noteIsMarkdown: boolean;
noteIsReadOnly: boolean;
noteIsReadOnlyShare: boolean;
@@ -129,6 +130,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
noteIsLocked,
noteLockContentUnavailable,
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
+ noteLockMigrationRunning: !!state.noteLockMigrationStatus?.running,
noteIsReadOnly: noteLockContentUnavailable || noteIsReadOnlyShare || noteIsLockedInShare(selectedNote),
noteIsReadOnlyShare,
noteIsDeleted: selectedNote ? !!selectedNote.deleted_time : false,
diff --git a/packages/lib/services/e2ee/EncryptionService.ts b/packages/lib/services/e2ee/EncryptionService.ts
index f75fe46ab1..2d3dbd6a78 100644
--- a/packages/lib/services/e2ee/EncryptionService.ts
+++ b/packages/lib/services/e2ee/EncryptionService.ts
@@ -810,9 +810,9 @@ export default class EncryptionService {
return `${isNoteLock ? 'JLD' : 'JED'}01${encryptionMetadata}`;
}
- public async decodeHeaderString(cipherText: string) {
+ public async decodeHeaderString(cipherText: string, isNoteLock = false) {
const source = this.stringReader_(cipherText);
- return this.decodeHeaderSource_(source);
+ return this.decodeHeaderSource_(source, isNoteLock);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See encryptAbstract_ source/destination
diff --git a/packages/lib/services/noteLock/NoteLockKey.ts b/packages/lib/services/noteLock/NoteLockKey.ts
index 8fdb6b6cfc..d366f8dbbc 100644
--- a/packages/lib/services/noteLock/NoteLockKey.ts
+++ b/packages/lib/services/noteLock/NoteLockKey.ts
@@ -1,4 +1,5 @@
import uuid from '../../uuid';
+import Setting from '../../models/Setting';
import EncryptionService from '../e2ee/EncryptionService';
import { MasterKeyEntity } from '../e2ee/types';
import { localSyncInfo, saveLocalSyncInfo } from '../synchronizer/syncInfoUtils';
@@ -39,6 +40,8 @@ export default class NoteLockKey {
const syncInfo = localSyncInfo();
syncInfo.noteLockKey = key;
+ // Only a key migration replaces the sync migration id, so a reset keeps the lineage it belongs to.
+ if (!syncInfo.syncMigrationId) syncInfo.syncMigrationId = uuid.create();
saveLocalSyncInfo(syncInfo);
return key;
@@ -56,7 +59,13 @@ export default class NoteLockKey {
// Rotate through NoteLockSession.reset() rather than calling this directly, so the session locks
// and drops the old key as part of the rotation.
public async reset(password: string) {
- return this.save(await this.encryptionService_.generateMasterKey(password));
+ const generated = await this.encryptionService_.generateMasterKey(password);
+ // Read once the key is ready, so a sync that finished meanwhile counts; a second reset before that sync keeps the first backup.
+ if (!Setting.value('noteLock.keyIdToReset')) Setting.setValue('noteLock.keyIdToReset', this.load()?.id ?? '');
+ const key = this.save(generated);
+ // Lets the next sync push the new key to the target instead of adopting the old one back.
+ Setting.setValue('noteLock.passwordReset', true);
+ return key;
}
public async changePassword(currentPassword: string, newPassword: string) {
diff --git a/packages/lib/services/noteLock/NoteLockKeyMigration.test.ts b/packages/lib/services/noteLock/NoteLockKeyMigration.test.ts
new file mode 100644
index 0000000000..a54b08d532
--- /dev/null
+++ b/packages/lib/services/noteLock/NoteLockKeyMigration.test.ts
@@ -0,0 +1,251 @@
+import Setting from '../../models/Setting';
+import Note from '../../models/Note';
+import uuid from '../../uuid';
+import { afterAllCleanUp, db, encryptionService, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
+import EncryptionService from '../e2ee/EncryptionService';
+import { MasterKeyEntity } from '../e2ee/types';
+import { localSyncInfo, noteLockKeyConflict } from '../synchronizer/syncInfoUtils';
+import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
+import NoteLockNote from './NoteLockNote';
+import NoteLockService from './NoteLockService';
+import NoteLockSession from './NoteLockSession';
+import { confirmAndSkipNoteLockKeyMigration, finishNoteLockKeyMigration, migrateLockedNotes, notMigratedMessage, startNoteLockKeyMigration } from './NoteLockKeyMigration';
+import eventManager, { EventName, NoteLockSessionChangeEvent } from '../../eventManager';
+import shim from '../../shim';
+
+const localPassword = '111111';
+const targetPassword = '222222';
+const targetSyncMigrationId = '0123456789abcdef0123456789abcdef';
+
+const generateKey = async (password: string): Promise => ({ ...await encryptionService().generateMasterKey(password), id: uuid.create() });
+
+const decryptKey = async (key: MasterKeyEntity, password: string): Promise => ({ id: key.id, plainText: await encryptionService().decryptMasterKeyContent(key, password) });
+
+// Encrypts outside the session so the fixture can use a key the session would refuse.
+const noteLockedWith = async (key: DecryptedNoteLockKey, body: string) => {
+ const cipherText = await encryptionService().encryptString(body, { masterKeyId: key.id, decryptedMasterKey: key.plainText, isNoteLock: true });
+ return Note.save({ title: body, body: cipherText, is_locked: 1 });
+};
+
+const bodyDecryptedWith = async (noteId: string, key: DecryptedNoteLockKey) => (await NoteLockNote.decryptBody(await Note.load(noteId), key)).body;
+
+const storedBodies = async () => (await Note.all({ fields: ['id', 'body'] })).map(n => n.body).sort();
+
+describe('NoteLockKeyMigration', () => {
+
+ let targetKey: MasterKeyEntity;
+ let decryptedTargetKey: DecryptedNoteLockKey;
+ let decryptedLocalKey: DecryptedNoteLockKey;
+
+ beforeEach(async () => {
+ await setupDatabaseAndSynchronizer(1);
+ await switchClient(1);
+ NoteLockService.destroyInstance();
+ NoteLockSession.destroyInstance();
+ NoteLockKey.destroyInstance();
+ EncryptionService.instance_ = encryptionService();
+ Setting.setValue('featureFlag.noteLock', true);
+
+ await NoteLockKey.instance().create(localPassword);
+ await NoteLockSession.instance().unlock(localPassword);
+ decryptedLocalKey = await NoteLockKey.instance().decrypt(localPassword);
+ targetKey = await generateKey(targetPassword);
+ decryptedTargetKey = await decryptKey(targetKey, targetPassword);
+ Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: targetKey, syncMigrationId: targetSyncMigrationId });
+ });
+
+ afterAll(async () => {
+ await afterAllCleanUp();
+ });
+
+ it('should re-encrypt the notes locked with the local key and report the rest', async () => {
+ const note1 = await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
+ const note2 = await Note.save({ title: 'two', body: 'two', is_locked: 1 }, { useNoteLock: true });
+ const staleKey = await generateKey('333333');
+ const staleNote = await noteLockedWith(await decryptKey(staleKey, '333333'), 'stale');
+ const corruptCipherText = (await Note.load(note1.id)).body;
+ const corruptNote = await Note.save({ title: 'corrupt', body: `${corruptCipherText.slice(0, -8)}xxxxxxxx`, is_locked: 1 });
+ await Note.save({ title: 'plain', body: 'plain' });
+ const localKeyBefore = NoteLockKey.instance().load();
+
+ const result = await migrateLockedNotes(localPassword, targetPassword);
+
+ expect(result).toEqual({ migrated: 2, skipped: 1, failed: 1, awaitingDecryption: 0 });
+ expect(await bodyDecryptedWith(note1.id, decryptedTargetKey)).toBe('one');
+ expect(await bodyDecryptedWith(note2.id, decryptedTargetKey)).toBe('two');
+ expect((await Note.load(staleNote.id)).body).toBe(staleNote.body);
+ expect((await Note.load(corruptNote.id)).body).toBe(corruptNote.body);
+ expect((await Note.load(note1.id)).is_locked).toBe(1);
+ // Nothing is adopted until the user finishes, so an interrupted migration loses no key.
+ expect(NoteLockKey.instance().load()).toEqual(localKeyBefore);
+ expect(noteLockKeyConflict()).toEqual({ noteLockKey: targetKey, syncMigrationId: targetSyncMigrationId });
+ });
+
+ it('should leave notes already under the target key alone on retry', async () => {
+ await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
+ const staleKey = await generateKey('333333');
+ await noteLockedWith(await decryptKey(staleKey, '333333'), 'stale');
+ await migrateLockedNotes(localPassword, targetPassword);
+ const bodiesAfterFirstRun = await storedBodies();
+
+ expect(await migrateLockedNotes(localPassword, targetPassword)).toEqual({ migrated: 0, skipped: 1, failed: 0, awaitingDecryption: 0 });
+ expect(await storedBodies()).toEqual(bodiesAfterFirstRun);
+ });
+
+ test.each([
+ ['local', 'wrong', targetPassword],
+ ['target', localPassword, 'wrong'],
+ ])('should not touch any note when the %s password is wrong', async (_which, local, target) => {
+ await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
+ const bodiesBefore = await storedBodies();
+
+ await expect(migrateLockedNotes(local, target)).rejects.toThrow();
+ expect(await storedBodies()).toEqual(bodiesBefore);
+ });
+
+ it('should fail closed when note lock is disabled', async () => {
+ await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
+ const bodiesBefore = await storedBodies();
+ Setting.setValue('featureFlag.noteLock', false);
+
+ await expect(migrateLockedNotes(localPassword, targetPassword)).rejects.toThrow('not enabled');
+ expect(await storedBodies()).toEqual(bodiesBefore);
+ });
+
+ it('should adopt the target key and lineage, clear the flags and lock the session on finish', async () => {
+ Setting.setValue('noteLock.passwordReset', true);
+ expect(NoteLockSession.instance().isUnlocked()).toBe(true);
+ const events: NoteLockSessionChangeEvent[] = [];
+ const listener = (event: NoteLockSessionChangeEvent) => events.push(event);
+ eventManager.on(EventName.NoteLockSessionChange, listener);
+
+ try {
+ finishNoteLockKeyMigration();
+
+ // The lock is announced right away rather than on the next session check, so the UI follows.
+ expect(events).toEqual([{ unlocked: false }]);
+ expect(NoteLockKey.instance().load()).toEqual(targetKey);
+ expect(localSyncInfo().syncMigrationId).toBe(targetSyncMigrationId);
+ expect(noteLockKeyConflict()).toBeNull();
+ expect(Setting.value('noteLock.passwordReset')).toBe(false);
+ await NoteLockSession.instance().unlock(targetPassword);
+ expect(NoteLockSession.instance().isUnlocked()).toBe(true);
+ } finally {
+ eventManager.off(EventName.NoteLockSessionChange, listener);
+ }
+ });
+
+ test.each([
+ ['Yes', 0, true],
+ ['No', 1, false],
+ ])('should skip the migration only when Yes is chosen in the warning (answer: %s)', async (_answer, response, skipped) => {
+ const showMessageBox = jest.spyOn(shim, 'showMessageBox').mockResolvedValue(response);
+ const dispatch = jest.fn();
+
+ expect(await confirmAndSkipNoteLockKeyMigration(dispatch)).toBe(skipped);
+
+ expect(showMessageBox.mock.calls[0][0]).toContain('JEX backup');
+ expect(showMessageBox.mock.calls[0][1].buttons).toEqual(['Yes', 'No']);
+ expect(NoteLockKey.instance().load().id).toBe(skipped ? targetKey.id : decryptedLocalKey.id);
+ expect(dispatch).toHaveBeenCalledTimes(skipped ? 1 : 0);
+ showMessageBox.mockRestore();
+ });
+
+ it('should report a background run that starts once the passwords are accepted', async () => {
+ const note = await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
+ const dispatch = jest.fn();
+ const onStarted = jest.fn();
+
+ await expect(startNoteLockKeyMigration(localPassword, 'wrong', dispatch, onStarted)).rejects.toThrow();
+ expect(onStarted).not.toHaveBeenCalled();
+ expect(dispatch).not.toHaveBeenCalled();
+
+ const corruptNote = await Note.save({ title: 'corrupt', body: `${(await Note.load(note.id)).body.slice(0, -8)}xxxxxxxx`, is_locked: 1 });
+ await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, onStarted);
+ expect(onStarted).toHaveBeenCalledTimes(1);
+ expect(dispatch.mock.calls).toEqual([
+ [{ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: true, failed: 0, skipped: 0, awaitingDecryption: 0 } }],
+ [{ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: false, failed: 1, skipped: 0, awaitingDecryption: 0 } }],
+ ]);
+ expect(noteLockKeyConflict()).not.toBeNull();
+
+ await Note.delete(corruptNote.id);
+ dispatch.mockClear();
+ await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, onStarted);
+ expect(dispatch).toHaveBeenLastCalledWith({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: false, failed: 0, skipped: 0, awaitingDecryption: 0 } });
+ expect(NoteLockKey.instance().load()).toEqual(targetKey);
+ expect(await bodyDecryptedWith(note.id, decryptedTargetKey)).toBe('one');
+ });
+
+ it('should cancel a run with a popup when the feature flag is turned off during it', async () => {
+ await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
+ const dispatch = jest.fn();
+ const showErrorDialog = jest.spyOn(shim, 'showErrorDialog').mockResolvedValue();
+ const save = Note.save.bind(Note);
+ const saveSpy = jest.spyOn(Note, 'save').mockImplementation((o, options) => {
+ Setting.setValue('featureFlag.noteLock', false);
+ return save(o, options);
+ });
+
+ try {
+ await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, jest.fn());
+
+ expect(dispatch).toHaveBeenLastCalledWith({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: null });
+ expect(showErrorDialog).toHaveBeenCalledWith('The note lock key migration was cancelled because the note lock feature was turned off.');
+ expect(noteLockKeyConflict()).not.toBeNull();
+ } finally {
+ saveSpy.mockRestore();
+ showErrorDialog.mockRestore();
+ }
+ });
+
+ it('should keep locked notes closed and refuse a password reset only while a run goes', async () => {
+ await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
+ let unlockDuringRun: Promise = null;
+ let resetDuringRun: Promise = null;
+ await migrateLockedNotes(localPassword, targetPassword, () => {
+ expect(NoteLockSession.instance().isUnlocked()).toBe(false);
+ unlockDuringRun = NoteLockSession.instance().unlock(localPassword).then((): Error => null, (error: Error) => error);
+ resetDuringRun = NoteLockSession.instance().reset('333333').then((): Error => null, (error: Error) => error);
+ });
+
+ expect((await unlockDuringRun)?.message).toContain('note lock key migration is in progress');
+ expect((await resetDuringRun)?.message).toContain('cannot be reset while a note lock key migration is in progress');
+ await NoteLockSession.instance().unlock(localPassword);
+ expect(NoteLockSession.instance().isUnlocked()).toBe(true);
+ });
+
+ it('should refuse to migrate or finish without a parked key', async () => {
+ Setting.setValue('noteLock.conflictNoteLockKey', {});
+
+ await expect(migrateLockedNotes(localPassword, targetPassword)).rejects.toThrow('No note lock key conflict');
+ expect(() => finishNoteLockKeyMigration()).toThrow('No note lock key conflict');
+ expect(NoteLockKey.instance().load().id).toBe(decryptedLocalKey.id);
+ });
+ it('should keep the date a note was last changed by the user', async () => {
+ const note = await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
+ await db().exec('UPDATE notes SET user_updated_time = 1000, updated_time = 1000 WHERE id = ?', [note.id]);
+
+ expect(await migrateLockedNotes(localPassword, targetPassword)).toEqual({ migrated: 1, skipped: 0, failed: 0, awaitingDecryption: 0 });
+
+ const migrated = await Note.load(note.id);
+ expect(migrated.user_updated_time).toBe(1000);
+ // Still moves, so the re-encrypted note is synced.
+ expect(migrated.updated_time).toBeGreaterThan(1000);
+ });
+
+ it('should leave a note still waiting for sync decryption for a retry and not finish the migration', async () => {
+ const waiting = await Note.save({ title: 'waiting', body: 'waiting', is_locked: 1 }, { useNoteLock: true });
+ // What sync leaves until the decryption worker has run: is_locked stays readable, the body is not there yet.
+ await db().exec('UPDATE notes SET encryption_applied = 1, body = \'\' WHERE id = ?', [waiting.id]);
+ const dispatch = jest.fn();
+
+ await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, jest.fn());
+
+ const status = { running: false, failed: 0, skipped: 0, awaitingDecryption: 1 };
+ expect(dispatch).toHaveBeenLastCalledWith({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: status });
+ expect(noteLockKeyConflict()).not.toBeNull();
+ expect(notMigratedMessage(status)).toBe('1 locked note is still waiting to be decrypted and was not migrated yet.');
+ });
+
+});
diff --git a/packages/lib/services/noteLock/NoteLockKeyMigration.ts b/packages/lib/services/noteLock/NoteLockKeyMigration.ts
new file mode 100644
index 0000000000..2e918c09f4
--- /dev/null
+++ b/packages/lib/services/noteLock/NoteLockKeyMigration.ts
@@ -0,0 +1,137 @@
+import Logger from '@joplin/utils/Logger';
+import { Dispatch } from 'redux';
+import { _, _n } from '../../locale';
+import type { NoteLockMigrationStatus } from '../../reducer';
+import Note from '../../models/Note';
+import time from '../../time';
+import shim, { MessageBoxType } from '../../shim';
+import EncryptionService from '../e2ee/EncryptionService';
+import { adoptNoteLockKeyConflict, noteLockKeyConflict } from '../synchronizer/syncInfoUtils';
+import isNoteLockEnabled from './isNoteLockEnabled';
+import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
+import NoteLockSession from './NoteLockSession';
+
+const logger = Logger.create('NoteLockKeyMigration');
+
+export interface NoteLockKeyMigrationResult {
+ migrated: number;
+ // Locked with a key that is neither the local nor the sync target one, so nothing here can read them.
+ skipped: number;
+ failed: number;
+ // Still encrypted by sync, so their note lock body cannot be read yet. A retry once they are decrypted picks them up.
+ awaitingDecryption: number;
+}
+
+// Re-encrypts the notes locked with the local key to the parked target key; notes already under it are left
+// alone, so a retry only touches the rest. The local key stays until finish, so an interrupted run loses nothing.
+// onStarted runs once both passwords are accepted.
+export const migrateLockedNotes = async (localPassword: string, targetPassword: string, onStarted: ()=> void = null) => {
+ if (!isNoteLockEnabled()) throw new Error('Note lock is not enabled');
+ const conflict = noteLockKeyConflict();
+ if (!conflict) throw new Error('No note lock key conflict to migrate');
+ const encryptionService = EncryptionService.instance();
+ const localKey = await NoteLockKey.instance().decrypt(localPassword);
+ const targetKey: DecryptedNoteLockKey = {
+ id: conflict.noteLockKey.id,
+ plainText: await encryptionService.decryptMasterKeyContent(conflict.noteLockKey, targetPassword),
+ };
+ const result: NoteLockKeyMigrationResult = { migrated: 0, skipped: 0, failed: 0, awaitingDecryption: 0 };
+ // A sync may have replaced the parked key while the passwords were checked. From here the sync leaves it alone.
+ if (noteLockKeyConflict()?.noteLockKey.id !== targetKey.id) throw new Error(_('The note lock key on the sync target changed. Please try again.'));
+ NoteLockSession.instance().setMigrating(true);
+ try {
+ onStarted?.();
+ for (const noteId of await Note.lockedNoteIds()) {
+ try {
+ const { body, encryption_applied } = await Note.load(noteId, { fields: ['id', 'body', 'encryption_applied'] });
+ if (encryption_applied) {
+ result.awaitingDecryption++;
+ continue;
+ }
+ const header = await encryptionService.decodeHeaderString(body, true);
+ if (header.masterKeyId === targetKey.id) continue;
+ if (header.masterKeyId !== localKey.id) {
+ result.skipped++;
+ continue;
+ }
+ const note = await Note.load(noteId, { useNoteLock: true, noteLockKey: localKey });
+ // The note keeps its own "updated" date; updated_time still moves so the re-encrypted note syncs.
+ await Note.save({ ...note, updated_time: time.unixMs() }, { useNoteLock: true, noteLockKey: targetKey, autoTimestamp: false });
+ result.migrated++;
+ } catch (error) {
+ logger.warn(`Could not migrate note ${noteId}:`, error);
+ result.failed++;
+ }
+ // With the feature turned off the note was not re-encrypted, whether Note.save refused it or saved it ungated.
+ if (!isNoteLockEnabled()) throw new Error(_('The note lock key migration was cancelled because the note lock feature was turned off.'));
+ }
+ } finally {
+ NoteLockSession.instance().setMigrating(false);
+ }
+ return result;
+};
+
+// Locked notes stay closed while a run goes, so after a run with no failures no note is left under the local key, apart
+// from an editor save that was already queued when the run started.
+export const finishNoteLockKeyMigration = () => {
+ adoptNoteLockKeyConflict();
+ NoteLockSession.instance().lock();
+};
+
+export const confirmAndSkipNoteLockKeyMigration = async (dispatch: Dispatch) => {
+ if (await shim.showMessageBox(_('Warning: Notes still locked with the current note lock key will become permanently unreadable. Before continuing, it is recommended to first export all notes on this device as a JEX backup, which can be imported again later if the correct password is used. Are you sure you want to continue without migrating the remaining notes?'), { buttons: [_('Yes'), _('No')], type: MessageBoxType.Confirm, cancelId: 1 }) !== 0) return false;
+ finishNoteLockKeyMigration();
+ dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: null });
+ return true;
+};
+
+// Runs in the background once both passwords are accepted, so the app stays usable, and reports its progress for the
+// banners. A run with failures keeps the target key parked for a retry or a skip.
+export const startNoteLockKeyMigration = async (localPassword: string, targetPassword: string, dispatch: Dispatch, onStarted: ()=> void) => {
+ let started = false;
+ let result: NoteLockKeyMigrationResult;
+ try {
+ result = await migrateLockedNotes(localPassword, targetPassword, () => {
+ started = true;
+ onStarted();
+ dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: true, failed: 0, skipped: 0, awaitingDecryption: 0 } });
+ });
+ } catch (error) {
+ // Before the start the dialog is still open to show the error, after it the dialog is gone.
+ if (!started) throw error;
+ dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: null });
+ await shim.showErrorDialog(error.message);
+ return;
+ }
+ if (!result.failed && !result.awaitingDecryption) finishNoteLockKeyMigration();
+ dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: false, failed: result.failed, skipped: result.skipped, awaitingDecryption: result.awaitingDecryption } });
+};
+
+// The notes a run left under the local key, so the banner offers a retry or a skip.
+export const notMigratedMessage = (status: NoteLockMigrationStatus) => {
+ const parts = [];
+ if (status.failed) parts.push(_n('%d locked note could not be migrated.', '%d locked notes could not be migrated.', status.failed, status.failed));
+ if (status.awaitingDecryption) parts.push(_n('%d locked note is still waiting to be decrypted and was not migrated yet.', '%d locked notes are still waiting to be decrypted and were not migrated yet.', status.awaitingDecryption, status.awaitingDecryption));
+ return withSkippedCount(parts.join(' '), status);
+};
+
+// Notes already moved to the parked key need it, so a sync must not drop or replace it.
+export const parkedNoteLockKeyInUse = async () => {
+ const conflict = noteLockKeyConflict();
+ if (!conflict) return false;
+ for (const noteId of await Note.lockedNoteIds()) {
+ const { body, encryption_applied } = await Note.load(noteId, { fields: ['id', 'body', 'encryption_applied'] });
+ if (encryption_applied) continue;
+ try {
+ if ((await EncryptionService.instance().decodeHeaderString(body, true)).masterKeyId === conflict.noteLockKey.id) return true;
+ } catch {
+ // A body that is not note lock ciphertext cannot use the parked key.
+ }
+ }
+ return false;
+};
+
+export const withSkippedCount = (message: string, status: NoteLockMigrationStatus) => {
+ if (!status.skipped) return message;
+ return `${message} ${_n('%d locked note was skipped because it is locked with another key.', '%d locked notes were skipped because they are locked with another key.', status.skipped, status.skipped)}`;
+};
diff --git a/packages/lib/services/noteLock/NoteLockSession.test.ts b/packages/lib/services/noteLock/NoteLockSession.test.ts
index 2da4b121da..123c3a346e 100644
--- a/packages/lib/services/noteLock/NoteLockSession.test.ts
+++ b/packages/lib/services/noteLock/NoteLockSession.test.ts
@@ -1,4 +1,5 @@
import { afterAllCleanUp, encryptionService, setupDatabaseAndSynchronizer, switchClient, synchronizerStart } from '../../testing/test-utils';
+import Setting from '../../models/Setting';
import EncryptionService from '../e2ee/EncryptionService';
import { localSyncInfo, saveLocalSyncInfo } from '../synchronizer/syncInfoUtils';
import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
@@ -139,6 +140,23 @@ describe('NoteLockSession', () => {
expect(session.isUnlocked()).toBe(false);
});
+ it('should keep the sync migration id and the first replaced key id through password resets', async () => {
+ const session = NoteLockSession.instance();
+ const firstKey = await NoteLockKey.instance().create('123456');
+ const syncMigrationId = localSyncInfo().syncMigrationId;
+ expect(syncMigrationId).toHaveLength(32);
+ expect(Setting.value('noteLock.passwordReset')).toBe(false);
+
+ await session.reset('654321');
+ expect(localSyncInfo().syncMigrationId).toBe(syncMigrationId);
+ expect(Setting.value('noteLock.passwordReset')).toBe(true);
+ expect(Setting.value('noteLock.keyIdToReset')).toBe(firstKey.id);
+
+ // A second reset before a sync still has to replace the key the sync target holds.
+ await session.reset('999999');
+ expect(Setting.value('noteLock.keyIdToReset')).toBe(firstKey.id);
+ });
+
it('should not install a key that was reset while unlocking', async () => {
const session = NoteLockSession.instance();
const originalKey = await NoteLockKey.instance().create('123456');
diff --git a/packages/lib/services/noteLock/NoteLockSession.ts b/packages/lib/services/noteLock/NoteLockSession.ts
index ff12bccf6e..67ff554c7b 100644
--- a/packages/lib/services/noteLock/NoteLockSession.ts
+++ b/packages/lib/services/noteLock/NoteLockSession.ts
@@ -1,5 +1,7 @@
import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
import eventManager, { EventName } from '../../eventManager';
+import { noteLockKeyConflict } from '../synchronizer/syncInfoUtils';
+import { _ } from '../../locale';
export default class NoteLockSession {
@@ -8,6 +10,7 @@ export default class NoteLockSession {
private key_: DecryptedNoteLockKey = null;
private lockGeneration_ = 0;
private rotating_ = false;
+ private migrating_ = false;
private constructor(private noteLockKey_: NoteLockKey = NoteLockKey.instance()) {}
@@ -29,6 +32,7 @@ export default class NoteLockSession {
// check covers it.
public async unlock(password: string) {
if (this.rotating_) throw new Error('Cannot unlock: a note lock key reset is in progress');
+ if (this.migrating_) throw new Error(_('This note cannot be read while a note lock key migration is in progress.'));
const generation = this.lockGeneration_;
const decrypted = await this.noteLockKey_.decrypt(password);
if (this.lockGeneration_ !== generation) throw new Error('Cannot unlock: the session was locked while unlocking');
@@ -47,6 +51,8 @@ export default class NoteLockSession {
// Blocks unlock during rotation so the still-persisted old key can't be unlocked before the new one is saved.
public async reset(password: string) {
if (this.rotating_) throw new Error('A note lock key reset is already in progress');
+ // The migration would adopt the target key over the new one when it ends.
+ if (this.migrating_) throw new Error(_('The note lock password cannot be reset while a note lock key migration is in progress.'));
this.rotating_ = true;
this.lock();
try {
@@ -57,6 +63,16 @@ export default class NoteLockSession {
}
}
+ // Locked notes stay closed in every window while a migration run re-encrypts them.
+ public setMigrating(migrating: boolean) {
+ this.migrating_ = migrating;
+ if (migrating) this.lock();
+ }
+
+ public isMigrating() {
+ return this.migrating_;
+ }
+
private lockIfKeyChanged_() {
if (this.key_ && this.key_.id !== this.noteLockKey_.load()?.id) this.lock();
}
@@ -76,7 +92,8 @@ export default class NoteLockSession {
}
// A lock clears key_ but not the persisted key, so this only fails on a real rotation, or one in progress.
+ // The key parked for migration is allowed too: migration re-encrypts to it before it is adopted.
public assertCanEncryptWith(keyId: string) {
- if (this.rotating_ || this.noteLockKey_.load()?.id !== keyId) throw new Error('Note lock key changed during operation');
+ if (this.rotating_ || (this.noteLockKey_.load()?.id !== keyId && noteLockKeyConflict()?.noteLockKey.id !== keyId)) throw new Error('Note lock key changed during operation');
}
}
diff --git a/packages/lib/services/noteLock/setNoteLockState.test.ts b/packages/lib/services/noteLock/setNoteLockState.test.ts
index e622491849..4a96b68911 100644
--- a/packages/lib/services/noteLock/setNoteLockState.test.ts
+++ b/packages/lib/services/noteLock/setNoteLockState.test.ts
@@ -84,6 +84,16 @@ describe('setNoteLockState', () => {
expect((await Note.load(note.id)).is_locked).toBe(0);
});
+ it('should refuse to change the lock state while a note lock key migration is required', async () => {
+ await setUpUnlockedSession();
+ const note = await Note.save({ title: 'note', body: 'secret' });
+ const lockedNote = await Note.save({ title: 'locked note', body: 'secret', is_locked: 1 }, { useNoteLock: true });
+ Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: NoteLockKey.instance().load(), syncMigrationId: 'remote' });
+
+ await expect(enableNoteLock(note.id)).rejects.toThrow('note lock key migration is required');
+ await expect(disableNoteLock(lockedNote.id)).rejects.toThrow('note lock key migration is required');
+ });
+
it('should throw when note lock is not enabled', async () => {
await setUpUnlockedSession();
const note = await Note.save({ title: 'note', body: 'secret' });
diff --git a/packages/lib/services/noteLock/setNoteLockState.ts b/packages/lib/services/noteLock/setNoteLockState.ts
index 6c9d1e063e..805a2197f3 100644
--- a/packages/lib/services/noteLock/setNoteLockState.ts
+++ b/packages/lib/services/noteLock/setNoteLockState.ts
@@ -8,6 +8,10 @@ import { NoteEntity } from '../database/types';
import eventManager, { EventName } from '../../eventManager';
import isNoteLockEnabled from './isNoteLockEnabled';
import NoteLockSession from './NoteLockSession';
+import { noteLockKeyConflict } from '../synchronizer/syncInfoUtils';
+import JoplinError from '../../JoplinError';
+import { ErrorCode } from '../../errors';
+import { _ } from '../../locale';
// The UI hides the enable/disable actions for these cases, but the commands can also be
// invoked directly (keyboard, plugins), so the transitions fail closed here too.
@@ -18,6 +22,8 @@ const checkCanChangeLockState = (note: NoteEntity, noteId: string) => {
if (note.is_conflict) throw new Error('Cannot change the note lock of a conflict note');
if (itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, note as ItemSlice, Setting.value('sync.userId'), BaseItem.syncShareCache)) throw new Error('Cannot change the note lock of a read-only note');
if (!NoteLockSession.instance().isUnlocked()) throw new Error('Cannot change the note lock while the session is locked');
+ // The migration re-encrypts the locked notes it finds, so a note locked or unlocked meanwhile could be missed or fail it.
+ if (noteLockKeyConflict()) throw new JoplinError(_('Notes cannot be locked or unlocked while a note lock key migration is required.'), ErrorCode.NoteLockKeyConflict);
};
const validationFields = ['id', 'is_locked', 'deleted_time', 'is_conflict', 'share_id'];
diff --git a/packages/lib/services/synchronizer/Synchronizer.noteLock.test.ts b/packages/lib/services/synchronizer/Synchronizer.noteLock.test.ts
new file mode 100644
index 0000000000..b6cae08ac9
--- /dev/null
+++ b/packages/lib/services/synchronizer/Synchronizer.noteLock.test.ts
@@ -0,0 +1,493 @@
+import Setting from '../../models/Setting';
+import Note from '../../models/Note';
+import { afterAllCleanUp, encryptionService, fileApi, loadEncryptionMasterKey, setupDatabaseAndSynchronizer, switchClient, synchronizer, synchronizerStart } from '../../testing/test-utils';
+import EncryptionService from '../e2ee/EncryptionService';
+import NoteLockKey from '../noteLock/NoteLockKey';
+import NoteLockSession from '../noteLock/NoteLockSession';
+import NoteLockService from '../noteLock/NoteLockService';
+import { fetchSyncInfo, localSyncInfo, noteLockKeyConflict, onRevisionServiceSettingsChanged, saveLocalSyncInfo } from './syncInfoUtils';
+import { ErrorCode } from '../../errors';
+import NoteLockNote from '../noteLock/NoteLockNote';
+import { finishNoteLockKeyMigration, migrateLockedNotes } from '../noteLock/NoteLockKeyMigration';
+import { MasterKeyEntity } from '../e2ee/types';
+import { setupAndEnableEncryption } from '../e2ee/utils';
+
+// Duplicates the singleton reset from NoteLockSession.test.ts: the note lock singletons cache the
+// encryption service, so each client switch has to rebuild them.
+const switchToClient = async (id: number) => {
+ await switchClient(id);
+ NoteLockService.destroyInstance();
+ NoteLockSession.destroyInstance();
+ NoteLockKey.destroyInstance();
+ EncryptionService.instance_ = encryptionService();
+};
+
+const remoteNoteLockKeyId = async () => (await fetchSyncInfo(fileApi())).noteLockKey?.id;
+
+const decryptedRemoteKey = async (key: MasterKeyEntity) => ({ id: key.id, plainText: await encryptionService().decryptMasterKeyContent(key, '111111') });
+
+// Lands the action inside the running sync, after it took its local snapshot and before the conflict check.
+const duringTheLockedNotesCheck = (action: ()=> Promise) => {
+ const realHasLockedNotes = Note.hasLockedNotes.bind(Note);
+ const spy = jest.spyOn(Note, 'hasLockedNotes').mockImplementation(async () => {
+ spy.mockRestore();
+ await action();
+ return realHasLockedNotes();
+ });
+};
+
+describe('Synchronizer.noteLock', () => {
+
+ beforeEach(async () => {
+ await setupDatabaseAndSynchronizer(1);
+ await setupDatabaseAndSynchronizer(2);
+ await switchToClient(1);
+ });
+
+ afterAll(async () => {
+ await afterAllCleanUp();
+ });
+
+ it('should propagate a password reset to the sync target and then to other devices', async () => {
+ const firstKey = await NoteLockKey.instance().create('111111');
+ const syncMigrationId = localSyncInfo().syncMigrationId;
+ await Note.save({ title: 'locked', is_locked: 1 });
+ await synchronizerStart();
+
+ await switchToClient(2);
+ await synchronizerStart();
+ expect(NoteLockKey.instance().load().id).toBe(firstKey.id);
+ const resetKey = await NoteLockSession.instance().reset('222222');
+ await synchronizerStart();
+ expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
+ expect(Setting.value('noteLock.passwordReset')).toBe(false);
+
+ // Locked notes do not stop a same-lineage adoption: a reset is meant to reach every device.
+ await switchToClient(1);
+ await synchronizerStart();
+ expect(NoteLockKey.instance().load()).toEqual(resetKey);
+ expect(localSyncInfo().syncMigrationId).toBe(syncMigrationId);
+ });
+
+ it('should propagate the newest of repeated offline resets to the key they replaced', async () => {
+ const firstKey = await NoteLockKey.instance().create('111111');
+ await Note.save({ title: 'locked', is_locked: 1 });
+ await synchronizerStart();
+
+ await switchToClient(2);
+ await synchronizerStart();
+ await NoteLockSession.instance().reset('222222');
+ const lastResetKey = await NoteLockSession.instance().reset('333333');
+ expect(Setting.value('noteLock.keyIdToReset')).toBe(firstKey.id);
+ await synchronizerStart();
+ expect(await remoteNoteLockKeyId()).toBe(lastResetKey.id);
+ expect(Setting.value('noteLock.passwordReset')).toBe(false);
+ expect(Setting.value('noteLock.keyIdToReset')).toBe('');
+
+ await switchToClient(1);
+ await synchronizerStart();
+ expect(NoteLockKey.instance().load()).toEqual(lastResetKey);
+ });
+
+ it('should park the sync target key instead of propagating a reset when another device reset first', async () => {
+ Setting.setValue('featureFlag.noteLock', true);
+ await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ Setting.setValue('featureFlag.noteLock', true);
+ await synchronizerStart();
+ await NoteLockSession.instance().reset('222222');
+ await NoteLockSession.instance().unlock('222222');
+ const note = await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
+
+ // The other device resets and syncs first, so the key this reset replaced is gone from the target.
+ await switchToClient(1);
+ const winningKey = await NoteLockSession.instance().reset('333333');
+ await synchronizerStart();
+ expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
+
+ await switchToClient(2);
+ for (let i = 0; i < 2; i++) {
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
+ expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
+ expect(Setting.value('noteLock.conflictNoteLockKey').noteLockKey).toEqual(winningKey);
+ }
+
+ expect(await migrateLockedNotes('222222', '333333')).toEqual({ migrated: 1, skipped: 0, failed: 0, awaitingDecryption: 0 });
+ finishNoteLockKeyMigration();
+ expect(Setting.value('noteLock.passwordReset')).toBe(false);
+ expect(Setting.value('noteLock.keyIdToReset')).toBe('');
+ await synchronizerStart(null, { throwOnError: true });
+ expect(NoteLockKey.instance().load()).toEqual(winningKey);
+ expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
+ const key = await NoteLockKey.instance().decrypt('333333');
+ expect((await NoteLockNote.decryptBody(await Note.load(note.id), key)).body).toBe('secret body');
+ });
+
+ it('should drop a reset that another device overtook when no note depends on it', async () => {
+ await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ await synchronizerStart();
+ await NoteLockSession.instance().reset('222222');
+
+ await switchToClient(1);
+ const winningKey = await NoteLockSession.instance().reset('333333');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ await synchronizerStart();
+ expect(NoteLockKey.instance().load()).toEqual(winningKey);
+ expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
+ expect(Setting.value('noteLock.passwordReset')).toBe(false);
+ expect(Setting.value('noteLock.keyIdToReset')).toBe('');
+ expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({});
+ });
+
+ it('should stop the sync and park the remote key when a different lineage meets local locked notes', async () => {
+ const remoteKey = await NoteLockKey.instance().create('111111');
+ const remoteSyncMigrationId = localSyncInfo().syncMigrationId;
+ await synchronizerStart();
+
+ await switchToClient(2);
+ const localKey = await NoteLockKey.instance().create('222222');
+ const localSyncMigrationId = localSyncInfo().syncMigrationId;
+ await Note.save({ title: 'locked', is_locked: 1 });
+
+ for (let i = 0; i < 2; i++) {
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
+ expect(NoteLockKey.instance().load()).toEqual(localKey);
+ expect(localSyncInfo().syncMigrationId).toBe(localSyncMigrationId);
+ expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
+ expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({ noteLockKey: remoteKey, syncMigrationId: remoteSyncMigrationId });
+ }
+ });
+
+ it('should drop a local key that no note depends on and adopt the sync target key', async () => {
+ const remoteKey = await NoteLockKey.instance().create('111111');
+ const remoteSyncMigrationId = localSyncInfo().syncMigrationId;
+ await synchronizerStart();
+
+ await switchToClient(2);
+ await NoteLockKey.instance().create('222222');
+ await synchronizerStart();
+
+ expect(NoteLockKey.instance().load()).toEqual(remoteKey);
+ expect(localSyncInfo().syncMigrationId).toBe(remoteSyncMigrationId);
+ expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
+ expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({});
+ });
+
+ it('should keep the sync stopped and say how to migrate when note lock is off', async () => {
+ Setting.setValue('featureFlag.noteLock', true);
+ await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ Setting.setValue('featureFlag.noteLock', true);
+ const localKey = await NoteLockKey.instance().create('222222');
+ await NoteLockSession.instance().unlock('222222');
+ await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
+ Setting.setValue('featureFlag.noteLock', false);
+
+ for (let i = 0; i < 2; i++) {
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('Enable the note lock feature');
+ }
+ expect(NoteLockKey.instance().load()).toEqual(localKey);
+ });
+
+ it('should sync again after migrating local locked notes to the sync target key', async () => {
+ Setting.setValue('featureFlag.noteLock', true);
+ const remoteKey = await NoteLockKey.instance().create('111111');
+ const remoteSyncMigrationId = localSyncInfo().syncMigrationId;
+ await synchronizerStart();
+
+ await switchToClient(2);
+ Setting.setValue('featureFlag.noteLock', true);
+ await NoteLockKey.instance().create('222222');
+ await NoteLockSession.instance().unlock('222222');
+ const note = await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
+
+ expect(await migrateLockedNotes('222222', '111111')).toEqual({ migrated: 1, skipped: 0, failed: 0, awaitingDecryption: 0 });
+ finishNoteLockKeyMigration();
+ await synchronizerStart(null, { throwOnError: true });
+ expect(NoteLockKey.instance().load()).toEqual(remoteKey);
+ expect(localSyncInfo().syncMigrationId).toBe(remoteSyncMigrationId);
+ expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
+
+ await switchToClient(1);
+ await synchronizerStart();
+ const key = await NoteLockKey.instance().decrypt('111111');
+ expect((await NoteLockNote.decryptBody(await Note.load(note.id), key)).body).toBe('secret body');
+ });
+
+ it('should adopt the sync target key before the upload, so a note cannot be locked with the dropped key mid-sync', async () => {
+ Setting.setValue('featureFlag.noteLock', true);
+ const remoteKey = await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ Setting.setValue('featureFlag.noteLock', true);
+ await NoteLockKey.instance().create('222222');
+ await NoteLockSession.instance().unlock('222222');
+
+ // Tries to lock a note with the local key while the info.json upload is in flight.
+ const api = synchronizer().api();
+ const realPut = api.put.bind(api);
+ let lateLockError: Error = null;
+ const putSpy = jest.spyOn(api, 'put').mockImplementation(async (path, content, options) => {
+ if (path === 'info.json' && !lateLockError) {
+ try {
+ await Note.save({ title: 'late', body: 'late body', is_locked: 1 }, { useNoteLock: true });
+ } catch (error) {
+ lateLockError = error;
+ }
+ }
+ return realPut(path, content, options);
+ });
+ try {
+ await synchronizerStart(null, { throwOnError: true });
+ } finally {
+ putSpy.mockRestore();
+ }
+
+ expect(lateLockError?.message).toBe('Note lock session is locked');
+ expect(NoteLockKey.instance().load()).toEqual(remoteKey);
+ expect(noteLockKeyConflict()).toBeNull();
+ });
+
+ it('should keep a reset that lands mid-sync for the next sync instead of clearing its flag', async () => {
+ await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ await synchronizerStart();
+ let resetKey: MasterKeyEntity = null;
+ duringTheLockedNotesCheck(async () => { resetKey = await NoteLockSession.instance().reset('222222'); });
+ await synchronizerStart(null, { throwOnError: true });
+
+ expect(NoteLockKey.instance().load()).toEqual(resetKey);
+ expect(Setting.value('noteLock.passwordReset')).toBe(true);
+ await synchronizerStart(null, { throwOnError: true });
+ expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
+ expect(Setting.value('noteLock.passwordReset')).toBe(false);
+ });
+
+ it('should not spend a leftover reset flag on a key that moved mid-sync', async () => {
+ await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ await synchronizerStart();
+ // A flag left behind by an interrupted earlier sync.
+ Setting.setValue('noteLock.passwordReset', true);
+ let resetKey: MasterKeyEntity = null;
+ duringTheLockedNotesCheck(async () => { resetKey = await NoteLockSession.instance().reset('222222'); });
+
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed on this device');
+ expect(Setting.value('noteLock.passwordReset')).toBe(true);
+ await synchronizerStart(null, { throwOnError: true });
+ expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
+ expect(Setting.value('noteLock.passwordReset')).toBe(false);
+ });
+
+ it('should hand a reset that lands after the local save the key the sync target holds', async () => {
+ await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ await synchronizerStart();
+ const firstResetKey = await NoteLockSession.instance().reset('222222');
+ // Resets again while the sync is finishing up, after the merged info was saved locally.
+ const handler = synchronizer().lockHandler();
+ const realReleaseLock = handler.releaseLock.bind(handler);
+ let secondResetKey: MasterKeyEntity = null;
+ const spy = jest.spyOn(handler, 'releaseLock').mockImplementation(async (...args) => {
+ spy.mockRestore();
+ secondResetKey = await NoteLockSession.instance().reset('333333');
+ return realReleaseLock(...args);
+ });
+ await synchronizerStart(null, { throwOnError: true });
+
+ expect(await remoteNoteLockKeyId()).toBe(firstResetKey.id);
+ expect(Setting.value('noteLock.passwordReset')).toBe(true);
+ expect(Setting.value('noteLock.keyIdToReset')).toBe(firstResetKey.id);
+ await synchronizerStart(null, { throwOnError: true });
+ expect(await remoteNoteLockKeyId()).toBe(secondResetKey.id);
+ expect(Setting.value('noteLock.passwordReset')).toBe(false);
+ });
+
+ it('should back up the key the target holds when a sync finishes while a second reset is generating its key', async () => {
+ await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ await synchronizerStart();
+ const firstResetKey = await NoteLockSession.instance().reset('222222');
+ const service = encryptionService();
+ const realGenerate = service.generateMasterKey.bind(service);
+ const spy = jest.spyOn(service, 'generateMasterKey').mockImplementation(async (...args) => {
+ spy.mockRestore();
+ await synchronizerStart(null, { throwOnError: true });
+ return realGenerate(...args);
+ });
+ const secondResetKey = await NoteLockSession.instance().reset('333333');
+
+ expect(Setting.value('noteLock.keyIdToReset')).toBe(firstResetKey.id);
+ await synchronizerStart(null, { throwOnError: true });
+ expect(await remoteNoteLockKeyId()).toBe(secondResetKey.id);
+ });
+
+ it('should adopt the sync target key in the sync that first uses a local master key', async () => {
+ const remoteKey = await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ await setupAndEnableEncryption(encryptionService(), await loadEncryptionMasterKey());
+ await synchronizerStart(null, { throwOnError: true });
+
+ expect(NoteLockKey.instance().load()).toEqual(remoteKey);
+ });
+
+ it('should keep a password change that lands during the info upload', async () => {
+ await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ await synchronizerStart();
+
+ await switchToClient(1);
+ await NoteLockKey.instance().changePassword('111111', '222222');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ const api = synchronizer().api();
+ const realPut = api.put.bind(api);
+ const putSpy = jest.spyOn(api, 'put').mockImplementation(async (path, content, options) => {
+ if (path === 'info.json') {
+ putSpy.mockRestore();
+ await NoteLockKey.instance().changePassword('111111', '333333');
+ }
+ return realPut(path, content, options);
+ });
+ try {
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed on this device');
+ } finally {
+ putSpy.mockRestore();
+ }
+
+ await expect(NoteLockKey.instance().decrypt('333333')).resolves.toBeTruthy();
+ });
+
+ it('should not park the key again when the migration finishes while a sync is in flight', async () => {
+ Setting.setValue('featureFlag.noteLock', true);
+ const remoteKey = await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ Setting.setValue('featureFlag.noteLock', true);
+ await NoteLockKey.instance().create('222222');
+ await NoteLockSession.instance().unlock('222222');
+ await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
+ await migrateLockedNotes('222222', '111111');
+ duringTheLockedNotesCheck(async () => finishNoteLockKeyMigration());
+
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed on this device');
+ expect(noteLockKeyConflict()).toBeNull();
+ expect(NoteLockKey.instance().load()).toEqual(remoteKey);
+ await synchronizerStart(null, { throwOnError: true });
+ expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
+ });
+
+ it('should not write back a key that another device replaced after this sync fetched the target info', async () => {
+ await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+ const infoBeforeReset = await fileApi().get('info.json');
+
+ await switchToClient(2);
+ await synchronizerStart();
+ const resetKey = await NoteLockSession.instance().reset('222222');
+ await synchronizerStart();
+ const infoAfterReset = await fileApi().get('info.json');
+
+ // Client 1 reads the target before the reset lands, and has its own change to upload.
+ await switchToClient(1);
+ onRevisionServiceSettingsChanged('revisionService.ttlDays', 45);
+ await fileApi().put('info.json', infoBeforeReset);
+ duringTheLockedNotesCheck(() => fileApi().put('info.json', infoAfterReset));
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed during the sync');
+ expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
+
+ await synchronizerStart(null, { throwOnError: true });
+ expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
+ expect(NoteLockKey.instance().load().id).toBe(resetKey.id);
+ });
+
+ it('should stop the sync when a note lock key has no sync migration id', async () => {
+ await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ const remoteInfo = await fetchSyncInfo(fileApi());
+ remoteInfo.syncMigrationId = '';
+ await fileApi().put('info.json', remoteInfo.serialize());
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('migration ID');
+
+ const localInfo = localSyncInfo();
+ localInfo.syncMigrationId = '';
+ saveLocalSyncInfo(localInfo);
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('migration ID');
+ });
+ // Sets up client 2 with a locked note and a conflict parked against client 1's key on the sync target.
+ const parkConflictOnClient2 = async () => {
+ Setting.setValue('featureFlag.noteLock', true);
+ const remoteKey = await NoteLockKey.instance().create('111111');
+ await synchronizerStart();
+
+ await switchToClient(2);
+ Setting.setValue('featureFlag.noteLock', true);
+ const localKey = await NoteLockKey.instance().create('222222');
+ await NoteLockSession.instance().unlock('222222');
+ const note = await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
+ expect(noteLockKeyConflict()?.noteLockKey.id).toBe(remoteKey.id);
+ return { remoteKey, localKey, note };
+ };
+
+ it('should sync again when the sync target no longer conflicts, for example after switching to an empty one', async () => {
+ const { localKey } = await parkConflictOnClient2();
+
+ await fileApi().clearRoot();
+ await synchronizerStart(null, { throwOnError: true });
+
+ expect(noteLockKeyConflict()).toBeNull();
+ expect(NoteLockKey.instance().load()).toEqual(localKey);
+ expect(await remoteNoteLockKeyId()).toBe(localKey.id);
+ });
+
+ it('should keep the parked key once a migration has moved notes to it, even if the sync target changes', async () => {
+ const { remoteKey, note } = await parkConflictOnClient2();
+ const partner = await Note.save({ title: 'partner', body: 'partner body', is_locked: 1 }, { useNoteLock: true });
+ // The first note moves, the second fails, so the run stops halfway with a note already under the parked key.
+ const save = Note.save.bind(Note);
+ const spy = jest.spyOn(Note, 'save').mockImplementation(async (n, options) => {
+ if (n.id === partner.id) throw new Error('simulated failure');
+ return save(n, options);
+ });
+ try {
+ expect(await migrateLockedNotes('222222', '111111')).toMatchObject({ migrated: 1, failed: 1 });
+ } finally {
+ spy.mockRestore();
+ }
+
+ await fileApi().clearRoot();
+ await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
+ expect(noteLockKeyConflict()?.noteLockKey.id).toBe(remoteKey.id);
+ const key = await decryptedRemoteKey(remoteKey);
+ expect((await NoteLockNote.decryptBody(await Note.load(note.id), key)).body).toBe('secret body');
+ });
+
+});
diff --git a/packages/lib/services/synchronizer/syncInfoUtils.test.ts b/packages/lib/services/synchronizer/syncInfoUtils.test.ts
index 526a88f41c..5d954a7115 100644
--- a/packages/lib/services/synchronizer/syncInfoUtils.test.ts
+++ b/packages/lib/services/synchronizer/syncInfoUtils.test.ts
@@ -1,11 +1,13 @@
import { afterAllCleanUp, setupDatabaseAndSynchronizer, logger, switchClient, encryptionService, msleep, fileApi } from '../../testing/test-utils';
import MasterKey from '../../models/MasterKey';
-import { checkIfCanSync, localSyncInfo, localSyncInfoSelector, masterKeyEnabled, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyEnabled, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, checkSyncTargetIsValid, fetchSyncInfo, onRevisionServiceSettingsChanged, setAppMinVersion } from './syncInfoUtils';
+import { checkIfCanSync, localSyncInfo, localSyncInfoSelector, masterKeyEnabled, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyEnabled, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, checkSyncTargetIsValid, fetchSyncInfo, onRevisionServiceSettingsChanged, setAppMinVersion, checkNoteLockKeyConflict, checkNoteLockKeyMigrationId } from './syncInfoUtils';
import Setting from '../../models/Setting';
import BaseItem from '../../models/BaseItem';
import BaseModel from '../../models/BaseItem';
import Logger from '@joplin/utils/Logger';
import { State } from '../../reducer';
+import { MasterKeyEntity } from '../e2ee/types';
+import { ErrorCode } from '../../errors';
describe('syncInfoUtils', () => {
it('should keep selected sync info stable while the cache is unchanged', () => {
@@ -150,23 +152,104 @@ describe('syncInfoUtils', () => {
expect(mergeSyncInfos(syncInfo1, syncInfo2).appMinVersion).toBe('0.0.0');
});
- it('should merge sync target info and keep the latest note lock key', () => {
- const syncInfo1 = new SyncInfo();
- syncInfo1.noteLockKey = {
- id: '1',
- content: 'content1',
- updated_time: 100,
- };
+ const noteLockKey = (id: string, updated_time: number): MasterKeyEntity => ({ id, content: `content${id}-${updated_time}`, updated_time });
- const syncInfo2 = new SyncInfo();
- syncInfo2.noteLockKey = {
- id: '2',
- content: 'content2',
- updated_time: 200,
- };
+ const syncInfoWithNoteLockKey = (key: MasterKeyEntity, syncMigrationId: string) => {
+ const syncInfo = new SyncInfo();
+ syncInfo.noteLockKey = key;
+ syncInfo.syncMigrationId = syncMigrationId;
+ return syncInfo;
+ };
- expect(mergeSyncInfos(syncInfo1, syncInfo2).noteLockKey).toEqual(syncInfo2.noteLockKey);
- expect(mergeSyncInfos(new SyncInfo(), syncInfo1).noteLockKey).toEqual(syncInfo1.noteLockKey);
+ test.each([
+ ['the newest copy of the same key', noteLockKey('1', 100), 'L1', noteLockKey('1', 200), 'L1', false, 'remote'],
+ ['the local key when a password reset in the same lineage propagates', noteLockKey('2', 100), 'L1', noteLockKey('1', 200), 'L1', true, 'local'],
+ ['the remote key in the same lineage without a propagating reset', noteLockKey('2', 300), 'L1', noteLockKey('1', 200), 'L1', false, 'remote'],
+ ['the remote key from a different lineage even with a propagating reset', noteLockKey('2', 300), 'L2', noteLockKey('1', 200), 'L1', true, 'remote'],
+ ['the only key, when it is remote', null, '', noteLockKey('1', 200), 'L1', false, 'remote'],
+ ['the only key, when it is local', noteLockKey('1', 200), 'L1', null, '', false, 'local'],
+ ])('should merge sync target info and keep %s', (_description, localKey, localLineage, remoteKey, remoteLineage, resetPropagates, winner) => {
+ const local = syncInfoWithNoteLockKey(localKey, localLineage);
+ const remote = syncInfoWithNoteLockKey(remoteKey, remoteLineage);
+ const expected = winner === 'local' ? local : remote;
+
+ const merged = mergeSyncInfos(local, remote, { resetPropagates });
+
+ expect(merged.noteLockKey).toEqual(expected.noteLockKey);
+ expect(merged.syncMigrationId).toBe(expected.syncMigrationId);
+ });
+
+ it('should refuse to merge a different lineage while local notes depend on the local key', () => {
+ const local = syncInfoWithNoteLockKey(noteLockKey('2', 300), 'L2');
+ const remote = syncInfoWithNoteLockKey(noteLockKey('1', 200), 'L1');
+
+ expect(() => mergeSyncInfos(local, remote, { hasLocalLockedNotes: true })).toThrow('different lineage');
+ });
+
+ it('should stop the sync and park the remote key when lineages differ and local notes are locked', () => {
+ const local = syncInfoWithNoteLockKey(noteLockKey('2', 300), 'L2');
+ const remote = syncInfoWithNoteLockKey(noteLockKey('1', 200), 'L1');
+
+ expect(() => checkNoteLockKeyConflict(local, remote, true, false)).toThrow(expect.objectContaining({ code: ErrorCode.NoteLockKeyConflict }));
+ expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({ noteLockKey: remote.noteLockKey, syncMigrationId: 'L1' });
+ });
+
+ test.each([
+ ['lineages differ but no local note is locked', 'L2', false],
+ ['the keys share a lineage', 'L1', true],
+ ])('should let the sync continue when %s', (_description, localLineage, hasLocalLockedNotes) => {
+ const local = syncInfoWithNoteLockKey(noteLockKey('2', 300), localLineage);
+ const remote = syncInfoWithNoteLockKey(noteLockKey('1', 200), 'L1');
+
+ expect(() => checkNoteLockKeyConflict(local, remote, hasLocalLockedNotes, false)).not.toThrow();
+ expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({});
+ });
+
+ it('should keep rejecting syncs while notes depend on the parked key', () => {
+ const parked = { noteLockKey: noteLockKey('1', 200), syncMigrationId: 'L1' };
+ Setting.setValue('noteLock.conflictNoteLockKey', parked);
+ const local = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3');
+ const remote = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3');
+
+ expect(() => checkNoteLockKeyConflict(local, remote, false, false, true)).toThrow(expect.objectContaining({ code: ErrorCode.NoteLockKeyConflict }));
+ expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual(parked);
+ });
+
+ test.each([
+ ['the sync target has no key', null],
+ ['the sync target has the local key', noteLockKey('3', 300)],
+ ])('should drop a parked key that no note depends on when %s', (_description, remoteKey) => {
+ Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: noteLockKey('1', 200), syncMigrationId: 'L1' });
+ const local = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3');
+ const remote = syncInfoWithNoteLockKey(remoteKey, remoteKey ? 'L3' : '');
+
+ expect(() => checkNoteLockKeyConflict(local, remote, true, false)).not.toThrow();
+ expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({});
+ });
+
+ it('should park the new sync target key when it changed and no note depends on the old one', () => {
+ Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: noteLockKey('1', 200), syncMigrationId: 'L1' });
+ const local = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3');
+ const remote = syncInfoWithNoteLockKey(noteLockKey('4', 400), 'L4');
+
+ expect(() => checkNoteLockKeyConflict(local, remote, true, false)).toThrow(expect.objectContaining({ code: ErrorCode.NoteLockKeyConflict }));
+ expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({ noteLockKey: remote.noteLockKey, syncMigrationId: 'L4' });
+ });
+
+ test.each([
+ ['a key without a sync migration id', noteLockKey('1', 200), '', true],
+ ['a key with a sync migration id', noteLockKey('1', 200), 'L1', false],
+ ['no key', null, '', false],
+ ])('should check the sync migration id: %s', (_description, key, syncMigrationId, throws) => {
+ const syncInfo = syncInfoWithNoteLockKey(key, syncMigrationId);
+ for (const [isRemote, side] of [[false, 'on this device'], [true, 'on the sync target']] as const) {
+ const check = () => checkNoteLockKeyMigrationId(syncInfo, isRemote);
+ if (throws) {
+ expect(check).toThrow(side);
+ } else {
+ expect(check).not.toThrow();
+ }
+ }
});
it('should merge sync target info and takes into account usage of master key - 1', async () => {
@@ -313,6 +396,7 @@ describe('syncInfoUtils', () => {
'value': '400227d2222c4d3bb7346514861c643b',
},
'appMinVersion': '0.0.0',
+ 'syncMigrationId': '',
'e2ee': {
'updatedTime': 0,
'value': true,
diff --git a/packages/lib/services/synchronizer/syncInfoUtils.ts b/packages/lib/services/synchronizer/syncInfoUtils.ts
index e11c3fffca..ce9c3a0ecf 100644
--- a/packages/lib/services/synchronizer/syncInfoUtils.ts
+++ b/packages/lib/services/synchronizer/syncInfoUtils.ts
@@ -12,6 +12,7 @@ import JoplinError from '../../JoplinError';
import { ErrorCode } from '../../errors';
import fastDeepEqual = require('fast-deep-equal');
import { createSelector } from 'reselect';
+import isNoteLockEnabled from '../noteLock/isNoteLockEnabled';
const logger = Logger.create('syncInfoUtils');
@@ -35,6 +36,17 @@ export interface SyncInfoValuePublicPrivateKeyPair {
updatedTime: number;
}
+export interface MergeSyncInfosOptions {
+ resetPropagates?: boolean;
+ hasLocalLockedNotes?: boolean;
+}
+
+// The sync target's note lock key and sync migration id, parked while a sync is stopped on a conflict.
+export interface NoteLockKeyConflict {
+ noteLockKey: MasterKeyEntity;
+ syncMigrationId: string;
+}
+
// 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
@@ -269,7 +281,7 @@ const mergeActiveMasterKeys = (s1: SyncInfo, s2: SyncInfo, output: SyncInfo) =>
};
// If there is a distinction, s1 should be local sync info and s2 remote.
-export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo): SyncInfo {
+export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo, options: MergeSyncInfosOptions = {}): SyncInfo {
const output: SyncInfo = new SyncInfo();
output.setWithTimestamp(s1.keyTimestamp('e2ee') > s2.keyTimestamp('e2ee') ? s1 : s2, 'e2ee');
@@ -292,15 +304,23 @@ export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo): SyncInfo {
}
}
- const noteLockKey1 = s1.noteLockKey;
- const noteLockKey2 = s2.noteLockKey;
- if (!noteLockKey1) {
- output.noteLockKey = noteLockKey2;
- } else if (!noteLockKey2) {
- output.noteLockKey = noteLockKey1;
+ let noteLockKeySource: SyncInfo;
+ if (!s2.noteLockKey) {
+ noteLockKeySource = s1;
+ } else if (!s1.noteLockKey) {
+ noteLockKeySource = s2;
+ } else if (s1.noteLockKey.id === s2.noteLockKey.id) {
+ noteLockKeySource = (s1.noteLockKey.updated_time || 0) >= (s2.noteLockKey.updated_time || 0) ? s1 : s2;
+ } else if (s1.syncMigrationId === s2.syncMigrationId) {
+ noteLockKeySource = options.resetPropagates ? s1 : s2;
+ } else if (!options.hasLocalLockedNotes) {
+ noteLockKeySource = s2;
} else {
- output.noteLockKey = (noteLockKey1.updated_time || 0) >= (noteLockKey2.updated_time || 0) ? noteLockKey1 : noteLockKey2;
+ // checkNoteLockKeyConflict() stops the sync before this point, so this only guards against a future caller skipping it.
+ throw new Error('Cannot merge a note lock key from a different lineage while local notes depend on the local key');
}
+ output.noteLockKey = noteLockKeySource.noteLockKey;
+ output.syncMigrationId = noteLockKeySource.syncMigrationId;
// 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
@@ -321,6 +341,7 @@ export class SyncInfo {
private activeMasterKeyId_: SyncInfoValueString;
private masterKeys_: MasterKeyEntity[] = [];
private noteLockKey_: MasterKeyEntity = null;
+ private syncMigrationId_ = '';
private ppk_: SyncInfoValuePublicPrivateKeyPair;
private appMinVersion_: string = appMinVersion_;
private revisionServiceEnabled_: SyncInfoValueBoolean;
@@ -343,6 +364,7 @@ export class SyncInfo {
activeMasterKeyId: this.activeMasterKeyId_,
masterKeys: this.masterKeys,
noteLockKey: this.noteLockKey,
+ syncMigrationId: this.syncMigrationId,
ppk: this.ppk_,
appMinVersion: this.appMinVersion,
revisionServiceEnabled: this.revisionServiceEnabled_,
@@ -393,6 +415,7 @@ export class SyncInfo {
this.activeMasterKeyId_ = 'activeMasterKeyId' in s ? s.activeMasterKeyId : { value: '', updatedTime: 0 };
this.masterKeys_ = 'masterKeys' in s ? s.masterKeys : [];
this.noteLockKey_ = 'noteLockKey' in s ? s.noteLockKey : null;
+ this.syncMigrationId_ = 'syncMigrationId' in s ? s.syncMigrationId : '';
this.ppk_ = 'ppk' in s ? s.ppk : { value: null, updatedTime: 0 };
this.appMinVersion_ = s.appMinVersion ? s.appMinVersion : '0.0.0';
this.revisionServiceEnabled_ = 'revisionServiceEnabled' in s ? s.revisionServiceEnabled : { value: true, updatedTime: 0 };
@@ -502,6 +525,16 @@ export class SyncInfo {
this.noteLockKey_ = v;
}
+ // Identifies the note lock key lineage: created with the first key, kept through password resets and
+ // replaced only when the user migrates to a sync target's key.
+ public get syncMigrationId(): string {
+ return this.syncMigrationId_;
+ }
+
+ public set syncMigrationId(v: string) {
+ this.syncMigrationId_ = v;
+ }
+
public keyTimestamp(name: string): number {
const self = this as unknown as Record;
if (!(`${name}_` in self)) throw new Error(`Invalid name: ${name}`);
@@ -576,8 +609,7 @@ export const setMasterKeyHasBeenUsed = (s: SyncInfo, mkId: string) => {
updated_time: Date.now(),
};
- saveLocalSyncInfo(s);
-
+ // Saved by the caller with the rest of the merge, so the sync never persists a candidate its checks may still reject.
return s;
};
@@ -604,6 +636,83 @@ export function masterKeyById(id: string) {
return localSyncInfo().masterKeys.find(mk => mk.id === id);
}
+const noteLockKeyConflictSettingKey = 'noteLock.conflictNoteLockKey';
+
+export const noteLockKeyConflict = (): NoteLockKeyConflict | null => {
+ const conflict = Setting.value(noteLockKeyConflictSettingKey) as Partial;
+ return conflict.noteLockKey ? conflict as NoteLockKeyConflict : null;
+};
+
+export const noteLockKeyConflictFromState = (state: State) => {
+ return isNoteLockEnabled() && !!(state.settings[noteLockKeyConflictSettingKey] as Partial)?.noteLockKey;
+};
+
+// Replaces the local key and lineage with the sync target's, so callers migrate first or warn: notes still locked
+// with the local key become unreadable. A pending reset belonged to the dropped lineage, so it is cleared too.
+export const adoptNoteLockKeyConflict = () => {
+ const conflict = noteLockKeyConflict();
+ if (!conflict) throw new Error('No note lock key conflict to adopt');
+ const syncInfo = localSyncInfo();
+ syncInfo.noteLockKey = conflict.noteLockKey;
+ syncInfo.syncMigrationId = conflict.syncMigrationId;
+ saveLocalSyncInfo(syncInfo);
+ Setting.setValue(noteLockKeyConflictSettingKey, {});
+ Setting.setValue('noteLock.passwordReset', false);
+ Setting.setValue('noteLock.keyIdToReset', '');
+};
+
+// Different lineages with local notes depending on the local key stop the sync: the remote key is parked for the
+// migration and keeps rejecting syncs until resolved. A stale reset (another device reset first) counts as a different lineage.
+// A parked key is checked again against the current target, for example after switching to another sync target, unless a
+// migration is running or has already moved notes to it (parkedKeyInUse), since those notes need it.
+export const checkNoteLockKeyConflict = (local: SyncInfo, remote: SyncInfo, hasLocalLockedNotes: boolean, staleReset: boolean, parkedKeyInUse = false) => {
+ const parked = noteLockKeyConflict();
+ if (!parked || !parkedKeyInUse) {
+ const keysDiffer = !!local.noteLockKey && !!remote.noteLockKey && local.noteLockKey.id !== remote.noteLockKey.id;
+ // The scenarios where a conflict occurs are when a different device has set up note lock for the first time (noteLockKey
+ // and syncMigrationId both differ) and when both remote and local have reset the password at the point of syncing (which
+ // makes staleReset true). staleReset is false both when a local password reset should propagate and when only a remote
+ // password reset was made (which silently accepts the remote key), and syncMigrationId is what tells a password reset
+ // apart from a key created for the first time.
+ if (!keysDiffer || (local.syncMigrationId === remote.syncMigrationId && !staleReset) || !hasLocalLockedNotes) {
+ if (parked) Setting.setValue(noteLockKeyConflictSettingKey, {});
+ return;
+ }
+ const conflict: NoteLockKeyConflict = { noteLockKey: remote.noteLockKey, syncMigrationId: remote.syncMigrationId };
+ if (!parked || parked.syncMigrationId !== conflict.syncMigrationId || parked.noteLockKey.id !== conflict.noteLockKey.id || parked.noteLockKey.updated_time !== conflict.noteLockKey.updated_time) {
+ Setting.setValue(noteLockKeyConflictSettingKey, conflict);
+ }
+ }
+ throw new JoplinError(isNoteLockEnabled()
+ ? _('Synchronisation was stopped because the sync target uses a different note lock key to the one on your device. Your locked notes must be migrated to the synced key before synchronisation can continue.')
+ : _('Synchronisation was stopped because a note lock key migration is required. Enable the note lock feature to migrate your locked notes.'), ErrorCode.NoteLockKeyConflict);
+};
+
+const sameNoteLockKey = (a: SyncInfo, b: SyncInfo) => {
+ return a.noteLockKey?.id === b.noteLockKey?.id && a.noteLockKey?.updated_time === b.noteLockKey?.updated_time && a.syncMigrationId === b.syncMigrationId;
+};
+
+// A reset, password change or migration that lands while a sync is in flight would be clobbered by the sync's
+// local save, so the sync stops instead and the next one starts from the new state.
+export const checkNoteLockKeyUnchanged = (snapshot: SyncInfo) => {
+ if (sameNoteLockKey(localSyncInfo(), snapshot)) return;
+ throw new Error(_('Synchronisation was stopped because the note lock key changed on this device during the sync. Please synchronise again.'));
+};
+
+export const checkRemoteNoteLockKeyUnchanged = (fetched: SyncInfo, latest: SyncInfo) => {
+ if (sameNoteLockKey(fetched, latest)) return;
+ throw new Error(_('Synchronisation was stopped because the note lock key on the sync target changed during the sync. Please synchronise again.'));
+};
+
+// Sync migration ids predate the release of the note lock feature, so a key without one comes from a
+// development build and cannot take part in the reset and migration handling.
+export const checkNoteLockKeyMigrationId = (s: SyncInfo, isRemote: boolean) => {
+ if (!s.noteLockKey || s.syncMigrationId) return;
+ throw new Error(isRemote
+ ? _('Synchronisation was stopped because the note lock key on the sync target has no sync migration ID.')
+ : _('Synchronisation was stopped because the note lock key on this device has no sync migration ID. Please reset the note lock password.'));
+};
+
export const checkIfCanSync = (s: SyncInfo, appVersion: string) => {
const isForwardCompatible = () => {
// Forward compatibility: This version of Joplin supports the Joplin 3.7 sync target format