mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
Desktop, Mobile: Resolves #16382: Migrate locked notes when the sync target uses a different note lock key (#16723)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
This commit is contained in:
1 parent
94168d33d6
commit
75f888ff0d
37 files changed
+1863
-42
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -787,6 +787,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
noteTitle={lockedNoteMetadata.title}
|
||||
hasNoteLockKey={props.hasNoteLockKey}
|
||||
dispatch={props.dispatch}
|
||||
migrationRunning={props.noteLockMigrationRunning}
|
||||
lockedInShare={noteIsLockedInShare(lockedNoteMetadata)}
|
||||
/>
|
||||
</div>
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 <p className="message">{_('This note cannot be read while a note lock key migration is in progress.')}</p>;
|
||||
}
|
||||
|
||||
if (props.undecryptable) {
|
||||
return <p className="message">{_('This note could not be unlocked. If it was locked prior to a password reset, the content is no longer recoverable.')}</p>;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface NoteEditorProps {
|
||||
whiteboardForceMarkdown: Record<string, boolean>;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
hasNoteLockKey: boolean;
|
||||
noteLockMigrationRunning: boolean;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(<NoteLockMigrationDialog themeId={1} dispatch={dispatch}/>);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
setLocalPassword(event.target.value);
|
||||
}, []);
|
||||
|
||||
const onTargetPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Dialog onCancel={migrating ? undefined : onClose} className="note-lock-migration-dialog">
|
||||
<div className="dialog-root">
|
||||
<DialogTitle title={_('Migrate locked notes')}/>
|
||||
<div className="dialog-content">
|
||||
<p>{_('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.')}</p>
|
||||
<div className="form">
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Note lock password on this device')}
|
||||
value={localPassword}
|
||||
onChange={onLocalPasswordChange}
|
||||
/>
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Note lock password on the sync target')}
|
||||
value={targetPassword}
|
||||
onChange={onTargetPasswordChange}
|
||||
/>
|
||||
</div>
|
||||
{!!errorMessage && <p className="error-message" role="alert">{errorMessage}</p>}
|
||||
</div>
|
||||
<DialogButtonRow
|
||||
themeId={props.themeId}
|
||||
onClick={onButtonRowClick}
|
||||
okButtonLabel={_('Migrate')}
|
||||
okButtonDisabled={!localPassword || !targetPassword || migrating}
|
||||
cancelButtonDisabled={migrating}
|
||||
customButtons={[{ name: 'skip', label: _('Skip'), disabled: migrating }]}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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> = 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> = 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,
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
@@ -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<string, RegisteredDialog> = {
|
||||
},
|
||||
},
|
||||
|
||||
noteLockMigration: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
|
||||
render: (props: RegisteredDialogProps, customProps: any) => {
|
||||
return <NoteLockMigrationDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId} {...customProps}/>;
|
||||
},
|
||||
},
|
||||
|
||||
editFolder: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
|
||||
render: (props: RegisteredDialogProps, customProps: any) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AppState>;
|
||||
@@ -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(<WarningBannerWrapper store={mock.store}/>);
|
||||
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(<WarningBannerWrapper store={mock.store}/>);
|
||||
|
||||
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();
|
||||
|
||||
@@ -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 => {
|
||||
}
|
||||
}, [props.mustUpgradeAppMessage, props.syncTargetAppMinVersion]);
|
||||
|
||||
const renderWarningBox = (key: string, message: string, target: WarningBoxTarget) => {
|
||||
const renderWarningBox = (key: string, message: string, target: WarningBoxTarget, actions?: WarningBoxAction[]) => {
|
||||
return <WarningBox
|
||||
key={key}
|
||||
themeId={props.themeId}
|
||||
target={target}
|
||||
message={message}
|
||||
testID='warning-box'
|
||||
actions={actions}
|
||||
/>;
|
||||
};
|
||||
|
||||
@@ -128,6 +137,22 @@ const WarningBannerComponent: React.FC<Props> = 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);
|
||||
@@ -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> = 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> = props => {
|
||||
|
||||
const hasTarget = !!propsRef.current.target;
|
||||
const bannerContent = <Text style={styles.text}>{props.message}</Text>;
|
||||
if (props.actions) {
|
||||
return (
|
||||
<View style={styles.container} testID={props.testID}>
|
||||
{bannerContent}
|
||||
{props.actions.map(action => (
|
||||
<TouchableOpacity key={action.title} onPress={action.onPress} accessibilityRole='button'>
|
||||
<Text style={styles.actionText}>{action.title}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (hasTarget) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -86,6 +86,7 @@ interface ScreenHeaderProps {
|
||||
|
||||
historyCanGoBack?: boolean;
|
||||
showShouldUpgradeSyncTargetMessage?: boolean;
|
||||
showNoteLockKeyConflictMessage?: boolean;
|
||||
|
||||
themeId: number;
|
||||
}
|
||||
@@ -731,6 +732,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
</View>
|
||||
<WarningBanner
|
||||
showShouldUpgradeSyncTargetMessage={this.props.showShouldUpgradeSyncTargetMessage}
|
||||
showNoteLockKeyConflictMessage={this.props.showNoteLockKeyConflictMessage}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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<ComponentProps, State> 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<ComponentProps, State> 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<ComponentProps, State> imp
|
||||
onPress: () => {
|
||||
this.enableNoteEncryption_onPress();
|
||||
},
|
||||
disabled: readOnly,
|
||||
disabled: readOnly || this.props.noteLockMigrationRunning,
|
||||
});
|
||||
} else {
|
||||
output.push({
|
||||
@@ -1923,6 +1925,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> 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);
|
||||
|
||||
|
||||
@@ -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 <Text style={styles.message}>{_('This note cannot be read while a note lock key migration is in progress.')}</Text>;
|
||||
}
|
||||
|
||||
if (props.undecryptable) {
|
||||
return (
|
||||
<Text style={styles.message}>{_('This note could not be unlocked. If it was locked prior to a password reset, the content is no longer recoverable.')}</Text>
|
||||
|
||||
@@ -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<AppState>;
|
||||
const renderScreen = () => {
|
||||
const dispatch = jest.fn();
|
||||
render(
|
||||
<TestProviderStack store={store}>
|
||||
<NoteLockMigrationScreenComponent themeId={Setting.THEME_LIGHT} dispatch={dispatch}/>
|
||||
</TestProviderStack>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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> = 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 (
|
||||
<View style={styles.root}>
|
||||
<ScreenHeader title={_('Migrate locked notes')} showSearchButton={false} showBackButton={!migrating} showNoteLockKeyConflictMessage={false}/>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.normalText}>{_('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.')}</Text>
|
||||
<Text nativeID={localLabelId} style={styles.normalText}>{_('Note lock password on this device')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabelledBy={localLabelId}
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
style={styles.passwordInput}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='password'
|
||||
value={localPassword}
|
||||
onChangeText={setLocalPassword}
|
||||
/>
|
||||
<Text nativeID={targetLabelId} style={styles.normalText}>{_('Note lock password on the sync target')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabelledBy={targetLabelId}
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
style={styles.passwordInput}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='password'
|
||||
value={targetPassword}
|
||||
onChangeText={setTargetPassword}
|
||||
/>
|
||||
{!!errorMessage && <Text style={styles.errorText} role='alert'>{errorMessage}</Text>}
|
||||
<View style={styles.buttonContainer}>
|
||||
<PrimaryButton onPress={onMigrate} disabled={!localPassword || !targetPassword || migrating}>{_('Migrate')}</PrimaryButton>
|
||||
<SecondaryButton onPress={onSkip} disabled={migrating}>{_('Skip')}</SecondaryButton>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
};
|
||||
})(NoteLockMigrationScreenComponent);
|
||||
@@ -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<AppComponentProps, AppComponentState>
|
||||
JoplinServerSamlLogin: { screen: SsoLoginScreen(new SamlShared()) },
|
||||
EncryptionConfig: { screen: EncryptionConfigScreen },
|
||||
UpgradeSyncTarget: { screen: UpgradeSyncTargetScreen },
|
||||
NoteLockMigration: { screen: NoteLockMigrationScreen },
|
||||
ShareManager: { screen: ShareManager },
|
||||
ProfileSwitcher: { screen: ProfileSwitcher },
|
||||
ProfileEditor: { screen: ProfileEditor },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,4 +6,5 @@ export enum ErrorCode {
|
||||
UnsupportedMimeType = 'unsupportedMimeType',
|
||||
MustUpgradeApp = 'mustUpgradeApp',
|
||||
DownloadLimiter = 'downloadLimiter',
|
||||
NoteLockKeyConflict = 'noteLockKeyConflict',
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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<NoteLockKeyConflict>,
|
||||
type: SettingItemType.Object,
|
||||
public: false,
|
||||
},
|
||||
|
||||
'featureFlag.autoUpdaterServiceEnabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
|
||||
@@ -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<State> = 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<MasterKeyEntity> => ({ ...await encryptionService().generateMasterKey(password), id: uuid.create() });
|
||||
|
||||
const decryptKey = async (key: MasterKeyEntity, password: string): Promise<DecryptedNoteLockKey> => ({ 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<Error> = null;
|
||||
let resetDuringRun: Promise<Error> = 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.');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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)}`;
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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<unknown>) => {
|
||||
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');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, { updatedTime: number }>;
|
||||
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<NoteLockKeyConflict>;
|
||||
return conflict.noteLockKey ? conflict as NoteLockKeyConflict : null;
|
||||
};
|
||||
|
||||
export const noteLockKeyConflictFromState = (state: State) => {
|
||||
return isNoteLockEnabled() && !!(state.settings[noteLockKeyConflictSettingKey] as Partial<NoteLockKeyConflict>)?.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
|
||||
|
||||
Reference in new issue
Block a user