Compare commits

..
9 Commits
Author SHA1 Message Date
Laurent Cozic 0c9e457db8 Server: Validate pagination parameters 2026-10-25 23:43:28 +01:00
Egzon Mjeku 588bd60825 All: Translation: Add sq.po (#16944) 2026-10-25 15:06:38 -04:00
renovate[bot] 3f19ce23b7 chore(deps): update dependency nan to v2.28.0 (#16928)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-25 13:58:47 +01:00
renovate[bot] 4fc42750cd fix(deps): update dependency knex to v3.3.0 (#16937)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-25 13:58:24 +01:00
renovate[bot] 62982077cf fix(deps): update dependency js-yaml to v4.3.0 (#16941)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-25 12:08:46 +01:00
Keshav 1d7c091d55 Desktop, Mobile, Cli: Resolves #16140: Add locked note handling to export and import (#16785) 2026-10-25 10:47:07 +00:00
mrjo118 bad38b7fff Android: Fixes #8099: Fix data share not working after leaving by back button (#16906) 2026-10-24 18:25:18 +02:00
KeshavandLaurent Cozic 75f888ff0d 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>
2026-10-24 18:23:40 +02:00
Ben BalterandClaude Opus 5.5 94168d33d6 Cli: Fixes #16894: Fix quadratic conversion time for large tables (#16901)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:42:49 +02:00
76 changed files with 11940 additions and 116 deletions

No files matched your search

+11
View File
@@ -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
@@ -712,6 +714,7 @@ packages/app-desktop/utils/initializeNetworkConnectionAttemptTimeout.js
packages/app-desktop/utils/isSafeToOpen.test.js
packages/app-desktop/utils/isSafeToOpen.js
packages/app-desktop/utils/layout/layoutKeyToLabel.js
packages/app-desktop/utils/noteLockPrompts.js
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
packages/app-desktop/utils/restartInSafeModeFromMain.js
packages/app-desktop/utils/sourceMapSetup.js
@@ -950,6 +953,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
@@ -1745,6 +1750,7 @@ packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
packages/lib/services/e2ee/types.js
packages/lib/services/e2ee/utils.test.js
packages/lib/services/e2ee/utils.js
packages/lib/services/interop/InteropService.noteLock.test.js
packages/lib/services/interop/InteropService.test.js
packages/lib/services/interop/InteropService.js
packages/lib/services/interop/InteropService_Exporter_Base.js
@@ -1804,6 +1810,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
@@ -1811,6 +1819,8 @@ packages/lib/services/noteLock/NoteLockSession.test.js
packages/lib/services/noteLock/NoteLockSession.js
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
packages/lib/services/noteLock/isNoteLockEnabled.js
packages/lib/services/noteLock/noteLockPrompts.test.js
packages/lib/services/noteLock/noteLockPrompts.js
packages/lib/services/noteLock/setNoteLockState.test.js
packages/lib/services/noteLock/setNoteLockState.js
packages/lib/services/ocr/OcrDriverBase.js
@@ -1965,6 +1975,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
+11
View File
@@ -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
@@ -738,6 +740,7 @@ packages/app-desktop/utils/initializeNetworkConnectionAttemptTimeout.js
packages/app-desktop/utils/isSafeToOpen.test.js
packages/app-desktop/utils/isSafeToOpen.js
packages/app-desktop/utils/layout/layoutKeyToLabel.js
packages/app-desktop/utils/noteLockPrompts.js
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
packages/app-desktop/utils/restartInSafeModeFromMain.js
packages/app-desktop/utils/sourceMapSetup.js
@@ -976,6 +979,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
@@ -1771,6 +1776,7 @@ packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
packages/lib/services/e2ee/types.js
packages/lib/services/e2ee/utils.test.js
packages/lib/services/e2ee/utils.js
packages/lib/services/interop/InteropService.noteLock.test.js
packages/lib/services/interop/InteropService.test.js
packages/lib/services/interop/InteropService.js
packages/lib/services/interop/InteropService_Exporter_Base.js
@@ -1830,6 +1836,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
@@ -1837,6 +1845,8 @@ packages/lib/services/noteLock/NoteLockSession.test.js
packages/lib/services/noteLock/NoteLockSession.js
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
packages/lib/services/noteLock/isNoteLockEnabled.js
packages/lib/services/noteLock/noteLockPrompts.test.js
packages/lib/services/noteLock/noteLockPrompts.js
packages/lib/services/noteLock/setNoteLockState.test.js
packages/lib/services/noteLock/setNoteLockState.js
packages/lib/services/ocr/OcrDriverBase.js
@@ -1991,6 +2001,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
+26
View File
@@ -2,6 +2,7 @@ import shim from '@joplin/lib/shim';
import * as os from 'os';
import { readFile } from 'fs/promises';
import { filename } from '@joplin/lib/path-utils';
import { dirname } from 'path';
import HtmlToMd, { ParseOptions } from '@joplin/lib/HtmlToMd';
import MdToHtml from '@joplin/renderer/MdToHtml';
@@ -130,6 +131,31 @@ describe('HtmlToMd', () => {
expect(collapsedResult).toContain('\n\n');
});
it('should not re-read the rows of a table for every row', async () => {
// Under domino, each access to `table.rows` walks the whole table, so reading it once per row made
// converting large tables quadratic: https://github.com/laurent22/joplin/issues/16894
const resolveFrom = (moduleName: string, fromPath: string) => require.resolve(moduleName, { paths: [dirname(fromPath)] });
const domino: { createDocument: (html: string)=> Document } = require(resolveFrom('@mixmark-io/domino', resolveFrom('@joplin/turndown', require.resolve('@joplin/lib/HtmlToMd'))));
const rowCount = 100;
const rows = Array.from({ length: rowCount }, (_, i) => `<tr><td>${i}</td><td>b</td><td>c</td></tr>`).join('');
const root = domino.createDocument(`<div><table><thead><tr><th>A</th><th>B</th><th>C</th></tr></thead><tbody>${rows}</tbody></table></div>`).querySelector('div');
// Turndown converts a clone of the element it's given, which wouldn't have the counter below
Object.defineProperty(root, 'cloneNode', { value: () => root });
const table = root.querySelector('table');
const getRows = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(table), 'rows').get;
let rowsReadCount = 0;
Object.defineProperty(table, 'rows', { get: () => {
rowsReadCount++;
return getRows.call(table);
} });
const markdown = new HtmlToMd().parse(root);
expect(markdown.split('\n')).toHaveLength(rowCount + 2);
expect(rowsReadCount).toBeLessThan(rowCount);
});
// Covers how newlines are carried between children when joining them, which was rewritten to fix
// https://github.com/laurent22/joplin/issues/16895
it.each([
+45 -2
View File
@@ -8,8 +8,12 @@ import { ExportModule } from '@joplin/lib/services/interop/Module';
import { _ } from '@joplin/lib/locale';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import bridge from './services/bridge';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import { unlockNoteLockSession } from '@joplin/lib/services/noteLock/noteLockPrompts';
import noteLockPrompts from './utils/noteLockPrompts';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import { friendlySafeFilename } from '@joplin/lib/path-utils';
import time from '@joplin/lib/time';
import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
@@ -36,6 +40,8 @@ export default class InteropServiceHelper {
format: ExportModuleOutputFormat.Html,
target: FileSystemItem.File,
sourceNoteIds: [noteId],
// The PDF export and printing ask first.
decryptLockedNotes: true,
customCss: '', ...exportOptions };
const service = InteropService.instance();
@@ -193,7 +199,30 @@ export default class InteropServiceHelper {
return `${filename}.${fileExtension}`;
}
public static async export(_dispatch: Dispatch, module: ExportModule, options: ExportNoteOptions = null) {
// Decrypting exports write locked notes out as plain text, so the user is asked even with the session unlocked,
// which only skips the password.
public static async confirmLockedNoteExport(dispatch: Dispatch, noteIds: string[], folderIds: string[] = null) {
if (!isNoteLockEnabled()) return 'export';
let hasLockedNotes = false;
if (noteIds?.length) {
hasLockedNotes = (await Note.byIds(noteIds, { fields: ['is_locked'] })).some(note => !!note.is_locked);
} else if (folderIds?.length) {
hasLockedNotes = await Note.hasLockedNoteInFolderTrees(folderIds);
} else {
// Same scope as the export of everything: notebooks not in the trash.
hasLockedNotes = await Note.hasLockedNoteInFolderTrees(await Folder.childrenIds(''));
}
if (!hasLockedNotes) return 'export';
const answer = bridge().showMessageBox(_('Some notes are locked and this export format requires them to be unlocked. Would you like to export these notes unprotected, or export without these notes?'), {
buttons: [_('Export unprotected'), _('Export without locked notes'), _('Cancel')],
});
if (answer === 1) return 'skipLocked';
if (answer === 0 && await unlockNoteLockSession(noteLockPrompts(dispatch))) return 'export';
return 'cancel';
}
public static async export(dispatch: Dispatch, module: ExportModule, options: ExportNoteOptions = null) {
if (!options) options = {};
let path = null;
@@ -214,6 +243,18 @@ export default class InteropServiceHelper {
if (Array.isArray(path)) path = path[0];
// Backups keep locked notes encrypted, so only the decrypting formats ask.
const isBackup = module.format === ExportModuleOutputFormat.Raw || module.format === ExportModuleOutputFormat.Jex;
const lockedNoteChoice = isBackup ? 'export' : await this.confirmLockedNoteExport(dispatch, options.sourceNoteIds, options.sourceFolderIds);
if (lockedNoteChoice === 'cancel') return;
let sourceNoteIds = options.sourceNoteIds;
// A selection left with no note would make a single-file export fail on the file it never wrote.
if (sourceNoteIds?.length && lockedNoteChoice === 'skipLocked') {
sourceNoteIds = (await Note.byIds(sourceNoteIds, { fields: ['id', 'is_locked'] })).filter(note => !note.is_locked).map(note => note.id);
if (!sourceNoteIds.length) return;
}
void CommandService.instance().execute('showModalMessage', _('Exporting to "%s" as "%s" format. Please wait...', path, module.format));
const exportOptions: ExportOptions = {};
@@ -225,7 +266,9 @@ export default class InteropServiceHelper {
exportOptions.target = module.target;
exportOptions.includeConflicts = !!options.includeConflicts;
if (options.sourceFolderIds) exportOptions.sourceFolderIds = options.sourceFolderIds;
if (options.sourceNoteIds) exportOptions.sourceNoteIds = options.sourceNoteIds;
if (sourceNoteIds) exportOptions.sourceNoteIds = sourceNoteIds;
exportOptions.skipLockedNotes = lockedNoteChoice === 'skipLocked';
exportOptions.decryptLockedNotes = lockedNoteChoice === 'export';
const service = InteropService.instance();
let keepModalOpen = false;
@@ -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>
);
}
@@ -8,6 +8,8 @@ interface Props {
value: string;
inputId: string;
onChange: ChangeEventHandler;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
inputRef?: React.Ref<HTMLInputElement>;
'aria-invalid'?: boolean;
'aria-errormessage'?: string;
@@ -34,6 +36,8 @@ const PasswordInput = (props: Props) => {
type={inputType}
value={props.value}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
ref={props.inputRef}
/>
<button type="button" onClick={onShowPassword} className="showpasswordbutton">
<i className={icon} role='img' aria-label={title} title={title}></i>
+8 -3
View File
@@ -9,6 +9,7 @@ import Dialog from '@joplin/lib/components/Dialog';
import { ChangeEvent } from 'react';
import { formatDateTimeLocalToMs, isValidDate } from '@joplin/utils/time';
import lightTheme from '@joplin/lib/themes/light';
import PasswordInput from './PasswordInput/PasswordInput';
interface Props {
themeId: number;
@@ -321,6 +322,8 @@ export default class PromptDialog extends React.Component<Props, any> {
} else if (this.props.inputType === 'dropdown') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- makeAnimated's generic doesn't unify with react-select's expected SelectComponents shape
inputComp = <Select className="item-selector" onMenuOpen={this.select_menuOpen} onMenuClose={this.select_menuClose} styles={styles.select} theme={styles.selectTheme} ref={this.answerInput_} components={makeAnimated() as any} value={this.props.answer} defaultValue={this.props.defaultValue} isClearable={false} options={this.props.autocomplete} onChange={onSelectChange} onKeyDown={onKeyDown} />;
} else if (this.props.inputType === 'password') {
inputComp = <PasswordInput inputId='prompt-dialog-password' inputRef={this.answerInput_} value={this.state.answer} onChange={event => onChange(event)} onKeyDown={event => onKeyDown(event)} />;
} else {
inputComp = <input style={styles.input} ref={this.answerInput_} value={this.state.answer} type="text" onChange={event => onChange(event)} onKeyDown={event => onKeyDown(event)} />;
}
@@ -355,10 +358,12 @@ export default class PromptDialog extends React.Component<Props, any> {
);
}
// A password prompt puts its input below the label at a fixed width, shrinking with the window.
const isPassword = this.props.inputType === 'password';
return (
<Dialog className='prompt-dialog' contentStyle={styles.dialog} onCancel={() => onClose(false, 'cancel')}>
<label style={styles.label}>{this.props.label ? this.props.label : ''}</label>
<div style={{ display: 'inline-block', color: 'black', backgroundColor: theme.backgroundColor }}>
<Dialog className='prompt-dialog' contentStyle={isPassword ? { boxSizing: 'border-box', width: 500, maxWidth: '100%' } : styles.dialog} onCancel={() => onClose(false, 'cancel')}>
<label style={isPassword ? { ...styles.label, display: 'block', marginBottom: 10 } : styles.label}>{this.props.label ? this.props.label : ''}</label>
<div style={{ display: isPassword ? 'block' : 'inline-block', color: 'black', backgroundColor: theme.backgroundColor }}>
{inputComp}
{descComp}
</div>
@@ -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',
};
};
@@ -19,6 +19,13 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
if (!noteIds.length) throw new Error('No notes selected for pdf export');
const lockedNoteChoice = await InteropServiceHelper.confirmLockedNoteExport(context.dispatch, noteIds);
if (lockedNoteChoice === 'cancel') return;
if (lockedNoteChoice === 'skipLocked') {
noteIds = (await Note.byIds(noteIds, { fields: ['id', 'is_locked'] })).filter(note => !note.is_locked).map(note => note.id);
if (!noteIds.length) return;
}
let path = null;
if (noteIds.length === 1) {
path = await bridge().showSaveDialog({
@@ -61,6 +68,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
}
},
enabledCondition: 'someNotesSelected',
enabledCondition: 'someNotesSelected && (!noteLockContentUnavailable || !noteLockSessionUnlocked)',
};
};
@@ -12,10 +12,25 @@ import shim from '@joplin/lib/shim';
import { ImportModule } from '@joplin/lib/services/interop/Module';
import { isRecoverableError } from '@joplin/lib/import-enex';
import Logger from '@joplin/utils/Logger';
import { Dispatch } from 'redux';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import { promptForImportedNoteLockKey } from '@joplin/lib/services/noteLock/noteLockPrompts';
import noteLockPrompts from '../../../utils/noteLockPrompts';
import { ErrorCode } from '@joplin/lib/errors';
const packageInfo: PackageInfo = require('../../../packageInfo.js');
const logger = Logger.create('importFrom');
const makeImportNoteLockKeyHandler = (modalMessage: string, dispatch: Dispatch) => async (backupKey: MasterKeyEntity) => {
// The password prompts are DOM dialogs and would render behind the import modal overlay.
void CommandService.instance().execute('hideModalMessage');
try {
return await promptForImportedNoteLockKey(backupKey, noteLockPrompts(dispatch));
} finally {
void CommandService.instance().execute('showModalMessage', modalMessage);
}
};
export const declaration: CommandDeclaration = {
name: 'importFrom',
label: () => _('Import...'),
@@ -143,6 +158,7 @@ export const runtime = (control: WindowControl): CommandRuntime => {
console.warn(error);
},
destinationFolderId: options.destinationFolderId,
onNoteLockKey: makeImportNoteLockKeyHandler(modalMessage, context.dispatch),
};
const service = InteropService.instance();
@@ -151,8 +167,10 @@ export const runtime = (control: WindowControl): CommandRuntime => {
// eslint-disable-next-line no-console
console.info('Import result: ', result);
} catch (error) {
logger.error(error);
bridge().showErrorMessageBox(error.message);
if (error.code !== ErrorCode.Cancelled) {
logger.error(error);
bridge().showErrorMessageBox(error.message);
}
}
void CommandService.instance().execute('hideModalMessage');
@@ -2,6 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale';
import { WindowControl } from '../utils/useWindowControl';
import bridge from '../../../services/bridge';
import InteropServiceHelper from '../../../InteropServiceHelper';
export const declaration: CommandDeclaration = {
name: 'print',
@@ -16,6 +17,8 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
try {
if (noteIds.length !== 1) throw new Error(_('Only one note can be printed at a time.'));
// A plugin can run this with any note, so a locked one is only printed once the user agrees, as for the PDF export.
if (await InteropServiceHelper.confirmLockedNoteExport(context.dispatch, noteIds) !== 'export') return;
await comp.printTo('printer', { noteId: noteIds[0] });
} catch (error) {
bridge().showErrorMessageBox(error.message);
@@ -10,6 +10,7 @@ enum PromptInputType {
Datetime = 'datetime',
Tags = 'tags',
Text = 'text',
Password = 'password',
}
interface PromptConfig {
@@ -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) => {
+7 -1
View File
@@ -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;
+1 -1
View File
@@ -207,7 +207,7 @@
"md5": "2.3.0",
"moment": "2.30.1",
"mustache": "4.2.0",
"nan": "2.27.0",
"nan": "2.28.0",
"node-notifier": "10.0.1",
"node-rsa": "1.1.1",
"pdfjs-dist": "3.11.174",
@@ -0,0 +1,19 @@
import { Dispatch } from 'redux';
import CommandService from '@joplin/lib/services/CommandService';
import { NoteLockPrompts } from '@joplin/lib/services/noteLock/noteLockPrompts';
const noteLockPrompts = (dispatch: Dispatch): NoteLockPrompts => ({
promptPassword: async label => {
const result: { answer: string|null } = await CommandService.instance().execute('showPrompt', { label, inputType: 'password' });
return result.answer;
},
goToNoteLockSetup: () => {
dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: { defaultSection: 'noteLock' },
});
},
});
export default noteLockPrompts;
@@ -45,7 +45,7 @@ public class SharePackage implements ReactPackage {
}
public static class ShareModule extends ReactContextBaseJavaModule implements ActivityEventListener, LifecycleEventListener {
private boolean handledStartIntent = false;
private Activity handledStartIntentActivity = null;
private Intent receivedShareIntent = null;
ShareModule(@NonNull ReactApplicationContext reactContext) {
@@ -206,15 +206,16 @@ public class SharePackage implements ReactPackage {
@Override
public void onHostResume() {
if (this.getCurrentActivity() != null) {
Intent intent = this.getCurrentActivity().getIntent();
if (this.handledStartIntent) {
// sometimes onHostResume is fired after onNewIntent
// and we only care about the activity intent when the first time app opens
return;
Activity currentActivity = this.getCurrentActivity();
if (currentActivity != null && currentActivity != this.handledStartIntentActivity) {
// Read the start intent once per activity. A later onNewIntent may be
// followed by onHostResume, while a share after Back may create a new activity.
this.handledStartIntentActivity = currentActivity;
Intent intent = currentActivity.getIntent();
// Recents can relaunch the original share intent, but it is not a new share.
if (intent != null && (intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) {
this.onNewIntent(intent);
}
this.handledStartIntent = true;
this.onNewIntent(intent);
}
}
@@ -223,6 +224,7 @@ public class SharePackage implements ReactPackage {
@Override
public void onHostDestroy() {
this.handledStartIntentActivity = null;
}
}
}
@@ -61,6 +61,8 @@ const TextInputDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) =
style={styles.textInput}
value={text}
onChangeText={setText}
secureTextEntry={dialog.secureTextEntry}
autoCapitalize={dialog.secureTextEntry ? 'none' : undefined}
// Underline styles are set via styles
underlineColorAndroid='transparent'
/>
@@ -92,7 +92,7 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
});
});
},
promptForText: (message: string, initialValue?: string) => {
promptForText: (message: string, initialValue?: string, secureTextEntry?: boolean) => {
return new Promise<string|null>((resolve) => {
const dismiss = () => {
onDismiss(dialog);
@@ -103,6 +103,7 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
key: `prompt-dialog-${nextDialogIdRef.current++}`,
message,
initialValue,
secureTextEntry,
onSubmit: (text) => {
resolve(text);
dismiss();
@@ -24,7 +24,7 @@ export interface DialogControl {
info(message: string): Promise<void>;
error(message: string): Promise<void>;
prompt(title: string, message: string, buttons?: PromptButtonSpec[], options?: PromptOptions): void;
promptForText(message: string, initialValue?: string): Promise<string>;
promptForText(message: string, initialValue?: string, secureTextEntry?: boolean): Promise<string>;
showMenu<IdType>(title: string, choices: MenuChoice<IdType>[]): Promise<IdType>;
}
@@ -48,6 +48,7 @@ export interface TextInputDialogData {
key: string;
message: string;
initialValue?: string;
secureTextEntry?: boolean;
onSubmit: (text: string)=> void;
onDismiss: ()=> void;
}
@@ -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>
);
@@ -583,7 +583,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
const importJexLabel = () => _('Import from JEX');
const importJexDescription = () => _('Import notes from a JEX (Joplin Export) file.');
addSettingComponent(
<NoteImportButton key='import_as_jex_button' styles={this.styles()} defaultTitle={importJexLabel()} description={importJexDescription()} format='jex' />,
<NoteImportButton key='import_as_jex_button' styles={this.styles()} defaultTitle={importJexLabel()} description={importJexDescription()} format='jex' onOpenNoteLockSetup={() => this.onJumpToSection_('noteLock')} />,
[importJexLabel(), importJexDescription()],
);
const importTxtLabel = () => _('Import from text file');
@@ -593,7 +593,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
return _('Import a note from a text file (%s). The note will be imported into notebook \'%s\'.', textImportExtensions.join(', '), substrWithEllipsis(folderTitle, 0, 32));
};
addSettingComponent(
<NoteImportButton key='import_as_txt_button' styles={this.styles()} defaultTitle={importTxtLabel()} description={importTxtDescription()} format='txt' activeFolder={this.state.activeFolder} />,
<NoteImportButton key='import_as_txt_button' styles={this.styles()} defaultTitle={importTxtLabel()} description={importTxtDescription()} format='txt' activeFolder={this.state.activeFolder} onOpenNoteLockSetup={() => this.onJumpToSection_('noteLock')} />,
[importTxtLabel(), importTxtDescription()],
);
addSettingComponent(
@@ -1,7 +1,7 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/utils/Logger';
import { FunctionComponent } from 'react';
import { FunctionComponent, useContext } from 'react';
import { join, basename } from 'path';
import { ConfigScreenStyles } from '../configScreenStyles';
import InteropService from '@joplin/lib/services/interop/InteropService';
@@ -13,6 +13,9 @@ import { Platform } from 'react-native';
import { FolderEntity } from '@joplin/lib/services/database/types';
import Folder from '@joplin/lib/models/Folder';
import { fileExtension } from '@joplin/lib/path-utils';
import { NoteLockPrompts, promptForImportedNoteLockKey } from '@joplin/lib/services/noteLock/noteLockPrompts';
import { ErrorCode } from '@joplin/lib/errors';
import { DialogContext } from '../../../DialogManager';
const logger = Logger.create('NoteImportButton');
@@ -22,6 +25,8 @@ interface Props {
description: string;
format: string;
activeFolder?: FolderEntity;
// Navigating to the Config screen does nothing while it is already open, so the screen switches its own section.
onOpenNoteLockSetup: ()=> void;
}
export const importedFolderTitle = () => {
@@ -42,6 +47,12 @@ const importedFolder = async () => {
};
const NoteImportButton: FunctionComponent<Props> = props => {
const dialogs = useContext(DialogContext);
const noteLockPrompts: NoteLockPrompts = {
promptPassword: label => dialogs.promptForText(label, '', true),
goToNoteLockSetup: props.onOpenNoteLockSetup,
};
const getTitle = (taskStatus: TaskStatus) => {
if (taskStatus === TaskStatus.InProgress) {
return _('Importing...');
@@ -96,11 +107,16 @@ const NoteImportButton: FunctionComponent<Props> = props => {
path: importTargetPath,
format: props.format,
destinationFolderId: activeFolderId,
onNoteLockKey: backupKey => promptForImportedNoteLockKey(backupKey, noteLockPrompts),
});
logger.info('Imported successfully');
return { success: true, warnings: status.warnings };
} catch (error) {
if (error.code === ErrorCode.Cancelled) {
logger.info('Canceled.');
return { success: false, warnings: [] };
}
logger.error('Import failed with error', error);
throw new Error(_('Import failed. Make sure a %s file was selected.\nDetails: %s', props.format, error.toString()));
}
@@ -476,6 +476,9 @@ describe('screens/Note/Note', () => {
});
expect(screen.getByDisplayValue('Shared then locked')).toBeDisabled();
await act(() => waitForWithRealTimers(async () => {
expect((await Note.load(noteId)).is_locked).toBe(1);
}));
unmount();
Setting.setValue('featureFlag.noteLock', false);
@@ -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);
+7
View File
@@ -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';
@@ -503,6 +504,11 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
}
this.urlOpenListener_ = Linking.addEventListener('url', this.handleOpenURL_);
// A new activity can remount this component with biometrics already done.
// Its native share event may have arrived before the ShareExtension listener was attached.
if (this.props.biometricsDone) {
void this.handleShareData();
}
BackButtonService.initialize(this.backButtonHandler_);
@@ -776,6 +782,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 },
+59 -3
View File
@@ -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) {
@@ -266,6 +266,37 @@ describe('convertNoteToMarkdown', () => {
expect(converted.deleted_time).toBe(0);
});
it('should not save a decrypted locked note when note lock is turned off during the conversion', async () => {
Setting.setValue('featureFlag.noteLock', true);
shim.showErrorDialog = jest.fn();
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
const folder = await Folder.save({ title: 'test_folder' });
const htmlNote = await Note.save({ title: 'test', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
const lockedNote = { ...(await Note.load(htmlNote.id)), is_locked: 1, isDecrypted: true };
await Note.save(lockedNote, { useNoteLock: true });
state.selectedNoteIds = [htmlNote.id];
// The backup copy is made between the gated load and the save.
const duplicate = Note.duplicate.bind(Note);
const spy = jest.spyOn(Note, 'duplicate').mockImplementation((...args) => {
Setting.setValue('featureFlag.noteLock', false);
return duplicate(...args);
});
try {
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
} finally {
spy.mockRestore();
}
expect(shim.showErrorDialog).toHaveBeenCalledTimes(1);
expect((await Note.load(htmlNote.id)).body).not.toContain('Hello');
});
it('should finish converting the remaining locked notes when the session locks mid-run', async () => {
Setting.setValue('featureFlag.noteLock', true);
shim.showErrorDialog = jest.fn();
@@ -10,7 +10,7 @@ import { ModelType } from '../BaseModel';
import ItemChange from '../models/ItemChange';
import Setting from '../models/Setting';
import isNoteLockEnabled from '../services/noteLock/isNoteLockEnabled';
import NoteLockNote from '../services/noteLock/NoteLockNote';
import NoteLockNote, { NoteLockNoteEntity } from '../services/noteLock/NoteLockNote';
import NoteLockSession from '../services/noteLock/NoteLockSession';
import Logger from '@joplin/utils/Logger';
@@ -50,11 +50,9 @@ export const runtime = (): CommandRuntime => {
if (await itemIsReadOnly(Note, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, note.id, Setting.value('sync.userId'), context.state.shareService)) {
throw new Error(_('Cannot convert read-only item: "%s"', note.title));
}
const noteIsLocked = isNoteLockEnabled() && NoteLockNote.isLocked(note);
// A locked note converts through a full gated load and save, so the body is
// Every note converts through a full gated load and save, so a locked body is
// decrypted for the conversion and the converted note is encrypted again.
const sourceNote = noteIsLocked ? await Note.load(note.id, { useNoteLock: true, noteLockKey }) : note;
const sourceNote: NoteLockNoteEntity = await Note.load(note.id, { useNoteLock: true, noteLockKey });
const markdownBody = await convertHtmlToMarkdown().execute(context, sourceNote.body);
const backupNote = await Note.duplicate(note.id, {
changes: {
@@ -67,7 +65,7 @@ export const runtime = (): CommandRuntime => {
note.markup_language = MarkupLanguage.Markdown;
note.updated_time = Date.now();
const toSave = { ...note, isDecrypted: true };
const toSave = { ...note, isDecrypted: sourceNote.isDecrypted };
await Note.save(toSave, { autoTimestamp: false, useNoteLock: true, noteLockKey });
await Note.delete(backupNote.id, { toTrash: true });
processedCount ++;
+10 -3
View File
@@ -7,6 +7,10 @@ const React = shim.react();
const { useEffect, useRef, useState } = shim.react();
type OnCancelListener = ()=> void;
// Closes made by the app, not the user, so the close event must not report them as a cancel.
const removedReturnValue = 'removed-from-dom';
const hiddenReturnValue = 'hidden';
type OnShowListener = ()=> void;
type OnBeforeShowListener = (dialog: HTMLDialogElement)=> void;
@@ -52,7 +56,8 @@ const Dialog: FC<Props> = props => {
dialogElement.showModal();
propsRef.current.onShow?.();
} else if (dialogElement.open && !open) {
dialogElement.close();
// The close event fires later, when a dialog shown next may already own onCancel.
dialogElement.close(hiddenReturnValue);
}
}, [dialogElement, contentRendered, props.open]);
@@ -168,9 +173,11 @@ const useDialogElement = (containerDocument: Document, onCancel: undefined|OnCan
}
});
const removedReturnValue = 'removed-from-dom';
dialog.addEventListener('close', () => {
const closedByCancel = dialog.returnValue !== removedReturnValue;
const closedByCancel = dialog.returnValue !== removedReturnValue && dialog.returnValue !== hiddenReturnValue;
// A cancel leaves the return value as it was, so it is cleared here rather than on show, which can
// happen before this event arrives.
dialog.returnValue = '';
if (closedByCancel) {
if (!preventAutoCloseOnCancel || !skipCloseCancelRef.current) {
onCancelRef.current?.();
+2
View File
@@ -6,4 +6,6 @@ export enum ErrorCode {
UnsupportedMimeType = 'unsupportedMimeType',
MustUpgradeApp = 'mustUpgradeApp',
DownloadLimiter = 'downloadLimiter',
Cancelled = 'cancelled',
NoteLockKeyConflict = 'noteLockKeyConflict',
}
+32
View File
@@ -405,6 +405,38 @@ describe('models/Note', () => {
expect((await Note.load(note.id)).body).toBe('overwrite');
});
it('should find a locked note anywhere in a notebook tree', async () => {
const parent = await Folder.save({ title: 'parent' });
const child = await Folder.save({ title: 'child', parent_id: parent.id });
const other = await Folder.save({ title: 'other' });
await Note.save({ title: 'plain', body: 'plain', parent_id: parent.id });
await Note.save({ title: 'other plain', body: 'plain', parent_id: other.id });
const locked = await Note.save({ title: 'locked', body: 'JLD01cipher', parent_id: child.id });
await db().exec('UPDATE notes SET is_locked = 1 WHERE id = ?', [locked.id]);
expect(await Note.hasLockedNoteInFolderTrees([parent.id])).toBe(true);
expect(await Note.hasLockedNoteInFolderTrees([child.id])).toBe(true);
expect(await Note.hasLockedNoteInFolderTrees([other.id])).toBe(false);
});
it('should refuse to save a decrypted locked note once the feature is disabled', async () => {
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
const note = await Note.save({ body: 'secret', is_locked: 1 }, { useNoteLock: true });
const lockedNote = await Note.load(note.id, { useNoteLock: true });
const unlockedNote = await Note.load((await Note.save({ body: 'plain' })).id, { useNoteLock: true });
const cipherText = (await Note.load(note.id)).body;
Setting.setValue('featureFlag.noteLock', false);
const error = 'Locked notes cannot be saved while the note lock feature is turned off';
await expect(Note.save({ ...lockedNote, body: 'edited' }, { useNoteLock: true })).rejects.toThrow(error);
await expect(Note.save({ id: note.id, body: 'edited', isDecrypted: true } as NoteEntity, { useNoteLock: true })).rejects.toThrow(error);
expect((await Note.load(note.id)).body).toBe(cipherText);
await Note.save({ ...unlockedNote, body: 'plain edited' }, { useNoteLock: true });
expect((await Note.load(unlockedNote.id)).body).toBe('plain edited');
});
it('should encrypt a gated save with a captured key while the session is locked, but not after a key rotation', async () => {
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
+50
View File
@@ -608,6 +608,49 @@ export default class Note extends BaseItem {
return r && r.total ? r.total : 0;
}
// Like the export of a notebook, the check includes its sub-notebooks.
public static async hasLockedNoteInFolderTrees(folderIds: string[]) {
if (!folderIds.length) return false;
const result = await this.db().selectOne(`
WITH RECURSIVE folder_tree(id) AS (
SELECT id
FROM folders
WHERE id IN (${this.escapeIdsForSql(folderIds)})
UNION
SELECT folder.id
FROM folders folder
INNER JOIN folder_tree parent
ON folder.parent_id = parent.id
WHERE folder.deleted_time = 0
)
SELECT EXISTS (
SELECT 1
FROM notes note
INNER JOIN folder_tree folder
ON note.parent_id = folder.id
WHERE note.is_locked = 1
AND note.is_conflict = 0
AND note.deleted_time = 0
LIMIT 1
) AS value
`);
return !!result.value;
}
// 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');
}
@@ -918,6 +961,8 @@ export default class Note extends BaseItem {
// before calling a gated save, but caution should be used if doing so, because if the body
// is ever not decrypted when doing so, it will cause the contents to be double encrypted,
// causing the plain text to become inaccessible in UI, with no automatic remedy available.
// It must also never be set to true without first checking that isNoteLockEnabled() is true,
// as a note marked decrypted is refused when the feature is turned off before the save.
public static async save(o: NoteEntity, options: SaveOptions = null): Promise<NoteEntity> {
const isNew = this.isNew(o, options);
@@ -960,6 +1005,11 @@ export default class Note extends BaseItem {
// in the item_changes table
const oldNote = !isNew && o.id ? await Note.load(o.id) : null;
let plainTextBodyToReturn: string = null;
// With the feature turned off the note lock path below is skipped, so a decrypted body would be written over the
// ciphertext. The stored row decides for a partial save, which may not carry the lock state.
if (!isNoteLockEnabled() && (o as NoteLockNoteEntity).isDecrypted && (NoteLockNote.isLocked(o) || NoteLockNote.isLocked(oldNote))) {
throw new Error('Locked notes cannot be saved while the note lock feature is turned off');
}
if (isNoteLockEnabled()) {
if (!options?.useNoteLock && o.is_locked !== undefined && 'body' in o && !!(o as NoteLockNoteEntity).isDecrypted) throw new Error('Saves including both is_locked and body fields must be gated when the body was loaded using a gated load');
if (!isNew && o.is_locked !== undefined && !!o.is_locked !== !!oldNote?.is_locked && !('body' in o)) throw new Error('Saves that change is_locked must include the body field');
@@ -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,
+1 -1
View File
@@ -78,7 +78,7 @@
"html-minifier": "4.0.0",
"image-data-uri": "2.0.1",
"immer": "9.0.21",
"js-yaml": "4.2.0",
"js-yaml": "4.3.0",
"markdown-it": "13.0.2",
"md5": "2.3.0",
"md5-file": "5.0.0",
+14
View File
@@ -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
@@ -0,0 +1,397 @@
import InteropService from './InteropService';
import { ExportModuleOutputFormat } from './types';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import Setting from '../../models/Setting';
import EncryptionService from '../e2ee/EncryptionService';
import NoteLockKey, { noteLockKeyFileName } from '../noteLock/NoteLockKey';
import NoteLockService from '../noteLock/NoteLockService';
import NoteLockSession from '../noteLock/NoteLockSession';
import Resource from '../../models/Resource';
import { createNoteAndResource, encryptionService, exportDir, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
import shim from '../../shim';
import * as fs from 'fs-extra';
const setUpUnlockedSession = async (password = '123456') => {
await NoteLockKey.instance().create(password);
await NoteLockSession.instance().unlock(password);
};
const lockNote = async (id: string) => {
const lockedNote = { ...(await Note.load(id)), is_locked: 1, isDecrypted: true };
await Note.save(lockedNote, { useNoteLock: true });
return Note.load(id);
};
const rotateProfileKey = async (newPassword: string) => {
NoteLockSession.instance().lock();
await NoteLockKey.instance().reset(newPassword);
await NoteLockSession.instance().unlock(newPassword);
};
describe('InteropService.noteLock', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
Setting.setValue('featureFlag.noteLock', true);
await fs.remove(exportDir());
await fs.mkdirp(exportDir());
});
it('should raw export a locked note with its ciphertext, resource and the key file', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const { note, resource } = await createNoteAndResource({ parentId: folder.id });
await Note.save({ id: note.id, title: 'note', body: `secret text ${note.body}` });
const locked = await lockNote(note.id);
expect(locked.body).not.toContain('secret');
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
const serialized = await fs.readFile(`${exportDir()}/${note.id}.md`, 'utf-8');
expect(serialized).not.toContain('secret');
expect(serialized).toContain('is_locked: 1');
expect(serialized).toContain(resource.id);
const exportedResources = await fs.readdir(`${exportDir()}/resources`);
expect(exportedResources.some(f => f.startsWith(resource.id))).toBe(true);
const keyFile = JSON.parse(await fs.readFile(`${exportDir()}/${noteLockKeyFileName}`, 'utf-8'));
expect(keyFile.id).toBe(NoteLockKey.instance().load().id);
});
it('should include the key file inside a jex archive', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
await lockNote(note.id);
const jexPath = `${exportDir()}/test.jex`;
await InteropService.instance().export({ path: jexPath, format: ExportModuleOutputFormat.Jex });
const extractDir = `${exportDir()}/extracted`;
await fs.mkdirp(extractDir);
await shim.fsDriver().tarExtract({ strict: true, portable: true, file: jexPath, cwd: extractDir });
expect(await fs.pathExists(`${extractDir}/${noteLockKeyFileName}`)).toBe(true);
});
it('should md export a locked note decrypted once the session is unlocked', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'locked note', body: 'secret text', parent_id: folder.id });
await lockNote(note.id);
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true });
const files = await fs.readdir(`${exportDir()}/folder`);
expect(files.length).toBe(1);
const exported = await fs.readFile(`${exportDir()}/folder/${files[0]}`, 'utf-8');
expect(exported).toContain('secret text');
expect(result.warnings).toEqual([]);
// The database row stays encrypted, only the exported copy is decrypted.
expect((await Note.load(note.id)).body).not.toContain('secret');
});
const saveLockedNotes = async (folderId: string, titles: string[]) => {
for (const title of titles) {
const note = await Note.save({ title, body: `secret ${title}`, parent_id: folderId });
await lockNote(note.id);
}
};
// Runs the action once, the first time a gated load or save starts.
const onFirstGatedCall = (method: 'load'|'save', action: ()=> void) => {
const original = (Note[method] as (...args: unknown[])=> Promise<unknown>).bind(Note);
let done = false;
return jest.spyOn(Note, method).mockImplementation(((...args: unknown[]) => {
if (!done && (args[1] as { useNoteLock?: boolean })?.useNoteLock) {
done = true;
action();
}
return original(...args);
}) as never);
};
it('should md export every locked note when the session is locked partway through', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await saveLockedNotes(folder.id, ['one', 'two']);
// Locks the session once the first note is done, as the second note starts loading.
const load = Note.load.bind(Note);
let plainLoads = 0;
const spy = jest.spyOn(Note, 'load').mockImplementation((id, options) => {
if (!options?.useNoteLock && ++plainLoads === 2) NoteLockSession.instance().lock();
return load(id, options);
});
try {
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true });
} finally {
spy.mockRestore();
}
const files = await fs.readdir(`${exportDir()}/folder`);
expect(files.length).toBe(2);
for (const file of files) {
expect(await fs.readFile(`${exportDir()}/folder/${file}`, 'utf-8')).toContain('secret');
}
});
it('should skip locked notes rather than write their ciphertext when the feature flag is turned off partway through', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await saveLockedNotes(folder.id, ['one', 'two']);
const spy = onFirstGatedCall('load', () => Setting.setValue('featureFlag.noteLock', false));
try {
await expect(InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true })).rejects.toThrow('2 locked notes could not be unlocked and were not exported.');
} finally {
spy.mockRestore();
}
expect(await fs.pathExists(`${exportDir()}/folder`) ? await fs.readdir(`${exportDir()}/folder`) : []).toEqual([]);
});
it('should skip and count locked notes when the session is locked', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'plain', body: 'plain text', parent_id: folder.id });
const locked = await Note.save({ title: 'locked', body: 'secret', parent_id: folder.id });
await lockNote(locked.id);
NoteLockSession.instance().lock();
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true });
expect(result.warnings).toEqual(['1 locked note(s) could not be unlocked and were not exported']);
expect(await fs.readdir(`${exportDir()}/folder`)).toEqual(['plain.md']);
});
it('should leave locked notes out without a warning when asked to, even with the session unlocked', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'plain', body: 'plain text', parent_id: folder.id });
const locked = await Note.save({ title: 'locked', body: 'secret', parent_id: folder.id });
await lockNote(locked.id);
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, skipLockedNotes: true });
expect(result.warnings).toEqual([]);
expect(await fs.readdir(`${exportDir()}/folder`)).toEqual(['plain.md']);
});
it('should not decrypt locked notes for a caller that did not ask, even with the session unlocked', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'plain', body: 'plain text', parent_id: folder.id });
const locked = await Note.save({ title: 'locked', body: 'secret', parent_id: folder.id });
await lockNote(locked.id);
// As the exportFolders command does when a plugin runs it.
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown });
expect(result.warnings).toEqual(['1 locked note(s) could not be unlocked and were not exported']);
expect(await fs.readdir(`${exportDir()}/folder`)).toEqual(['plain.md']);
});
it('should skip a locked note whose content cannot be decrypted', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'broken', body: 'not ciphertext', parent_id: folder.id, is_locked: 1 });
await expect(InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true })).rejects.toThrow('1 locked note could not be unlocked and was not exported.');
});
it('should not write the key file when no note is locked', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'note', body: 'plain body', parent_id: folder.id });
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
expect(await fs.pathExists(`${exportDir()}/${noteLockKeyFileName}`)).toBe(false);
});
it('should write the key file for a note locked with the profile key even with note lock disabled', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
await lockNote(note.id);
Setting.setValue('featureFlag.noteLock', false);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
expect(JSON.parse(await fs.readFile(`${exportDir()}/${noteLockKeyFileName}`, 'utf-8')).id).toBe(NoteLockKey.instance().load().id);
});
it('should not write the key file when the locked notes use another key', async () => {
await setUpUnlockedSession();
const otherKey = await encryptionService().generateMasterKey('other');
const body = await encryptionService().encryptString('secret', {
masterKeyId: '0123456789abcdef0123456789abcdef',
decryptedMasterKey: await encryptionService().decryptMasterKeyContent(otherKey, 'other'),
isNoteLock: true,
});
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'note', body, parent_id: folder.id, is_locked: 1 });
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
expect(await fs.pathExists(`${exportDir()}/${noteLockKeyFileName}`)).toBe(false);
});
it('should import a backup from the same profile with locked notes unchanged', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const { note, resource } = await createNoteAndResource({ parentId: folder.id });
await Note.save({ id: note.id, title: 'note', body: `secret ${note.body}` });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
expect(imported.body).not.toContain('secret');
expect((await Note.load(imported.id, { useNoteLock: true })).body).toContain('secret');
expect(result.warnings).toEqual([]);
// The extracted list follows the remapped resource id, so the resource stays associated.
const importedResourceIds = Note.unserializeExtractedResourceIds(imported.extracted_resource_ids);
expect(importedResourceIds.length).toBe(1);
expect(importedResourceIds[0]).not.toBe(resource.id);
expect(!!(await Resource.load(importedResourceIds[0]))).toBe(true);
});
it('should re-encrypt imported locked notes for this profile through the key handler', async () => {
await setUpUnlockedSession('old password');
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
await rotateProfileKey('new password');
const result = await InteropService.instance().import({
path: exportDir(),
format: 'raw',
onNoteLockKey: keyFile => NoteLockKey.instance().decrypt('old password', keyFile),
});
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
expect(imported.body).not.toContain('secret');
expect((await Note.load(imported.id, { useNoteLock: true })).body).toBe('secret old');
expect(result.warnings.length).toBe(0);
});
it('should re-encrypt every imported locked note when the session is locked partway through', async () => {
await setUpUnlockedSession('old password');
const folder = await Folder.save({ title: 'folder' });
await saveLockedNotes(folder.id, ['one', 'two']);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
const originalIds = (await Note.all()).map(n => n.id);
await rotateProfileKey('new password');
const spy = onFirstGatedCall('save', () => NoteLockSession.instance().lock());
try {
await InteropService.instance().import({
path: exportDir(),
format: 'raw',
onNoteLockKey: keyFile => NoteLockKey.instance().decrypt('old password', keyFile),
});
} finally {
spy.mockRestore();
}
await NoteLockSession.instance().unlock('new password');
const imported = (await Note.all()).filter(n => !originalIds.includes(n.id));
expect(imported.length).toBe(2);
for (const n of imported) expect((await Note.load(n.id, { useNoteLock: true })).body).toMatch(/^secret /);
});
it('should stop the import without writing plain text when the feature flag is turned off while a locked note is re-encrypted', async () => {
await setUpUnlockedSession('old password');
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
await rotateProfileKey('new password');
const save = Note.save.bind(Note);
const spy = jest.spyOn(Note, 'save').mockImplementation((o, options) => {
Setting.setValue('featureFlag.noteLock', false);
return save(o, options);
});
try {
await expect(InteropService.instance().import({
path: exportDir(),
format: 'raw',
onNoteLockKey: keyFile => NoteLockKey.instance().decrypt('old password', keyFile),
})).rejects.toThrow('Locked notes cannot be saved while the note lock feature is turned off');
} finally {
spy.mockRestore();
}
expect((await Note.all()).filter(n => n.body.includes('secret'))).toEqual([]);
});
it('should import foreign locked notes unchanged when no key handler is provided', async () => {
await setUpUnlockedSession('old password');
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
await rotateProfileKey('new password');
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
expect(imported.body).not.toContain('secret');
await expect(Note.load(imported.id, { useNoteLock: true })).rejects.toThrow();
expect(result.warnings).toEqual(['The locked notes in this backup have not been migrated to the current note lock key and will be unreadable']);
});
it('should warn when a backup with locked notes is imported without a handler into a profile with no note lock key', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
await setupDatabaseAndSynchronizer(2);
await switchClient(2);
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
Setting.setValue('featureFlag.noteLock', true);
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
expect(result.warnings).toEqual(['The locked notes in this backup cannot be read, because no note lock key has been set up on this profile']);
});
it('should keep locked notes unchanged and warn when the provided key does not fit', async () => {
await setUpUnlockedSession('old password');
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
await rotateProfileKey('new password');
const result = await InteropService.instance().import({
path: exportDir(),
format: 'raw',
onNoteLockKey: async keyFile => ({ id: keyFile.id, plainText: 'not the key' }),
});
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
expect(imported.body).not.toContain('secret');
expect(result.warnings.some(w => w.includes('could not be unlocked'))).toBe(true);
});
});
@@ -1,6 +1,6 @@
import { ModuleType, FileSystemItem, ImportModuleOutputFormat, ImportOptions, ExportOptions, ImportExportResult, ExportProgressState, ExportModuleOutputFormat } from './types';
import shim from '../../shim';
import { _ } from '../../locale';
import { _, _n } from '../../locale';
import BaseItem from '../../models/BaseItem';
import BaseModel, { ModelType } from '../../BaseModel';
import Resource from '../../models/Resource';
@@ -8,6 +8,11 @@ import Folder from '../../models/Folder';
import NoteTag from '../../models/NoteTag';
import Note from '../../models/Note';
import * as ArrayUtils from '../../ArrayUtils';
import isNoteLockEnabled from '../noteLock/isNoteLockEnabled';
import NoteLockNote, { NoteLockNoteEntity } from '../noteLock/NoteLockNote';
import { DecryptedNoteLockKey } from '../noteLock/NoteLockKey';
import NoteLockSession from '../noteLock/NoteLockSession';
import { NoteEntity } from '../database/types';
import InteropService_Importer_Jex from './InteropService_Importer_Jex';
import InteropService_Importer_Md from './InteropService_Importer_Md';
import InteropService_Importer_Md_frontmatter from './InteropService_Importer_Md_frontmatter';
@@ -360,6 +365,19 @@ export default class InteropService {
}
}
private async decryptedNoteForExport_(noteId: string, noteLockKey: DecryptedNoteLockKey): Promise<NoteEntity|null> {
if (!noteLockKey) return null;
try {
const decrypted: NoteLockNoteEntity = await Note.load(noteId, { useNoteLock: true, noteLockKey });
// Without the marker the load was not gated, as when the feature is turned off midway, so the body is still ciphertext.
if (!decrypted.isDecrypted) return null;
delete decrypted.isDecrypted;
return { ...decrypted, is_locked: 0, extracted_resource_ids: '' };
} catch {
return null;
}
}
public async export(options: ExportOptions): Promise<ImportExportResult> {
options = {
format: ExportModuleOutputFormat.Jex,
@@ -387,6 +405,14 @@ export default class InteropService {
const exportedNoteIds = [];
let resourceIds: string[] = [];
// Backups keep locked notes encrypted; other formats export them decrypted only when decryptLockedNotes is set.
const keepsLockedNotes = options.format === ExportModuleOutputFormat.Raw || options.format === ExportModuleOutputFormat.Jex;
let lockedNotesSkipped = 0;
// Read once, so enabling the feature partway through cannot leave the locked notes queued before it as ciphertext.
// Turning it off partway is caught by the gated load in decryptedNoteForExport_.
const noteLockEnabled = isNoteLockEnabled();
// Captured at the start, so locking the session partway through does not skip the remaining locked notes.
const noteLockKey = NoteLockSession.instance().isUnlocked() ? NoteLockSession.instance().decryptedKey() : null;
// Recursively get all the folders that have valid parents
const folderIds = await Folder.childrenIds('');
@@ -412,15 +438,32 @@ export default class InteropService {
for (let noteIndex = 0; noteIndex < noteIds.length; noteIndex++) {
const noteId = noteIds[noteIndex];
if (sourceNoteIds.length && sourceNoteIds.indexOf(noteId) < 0) continue;
const note = await Note.load(noteId);
let note = await Note.load(noteId);
if (noteLockEnabled && NoteLockNote.isLocked(note) && !keepsLockedNotes) {
if (options.skipLockedNotes) continue;
const decrypted = options.decryptLockedNotes ? await this.decryptedNoteForExport_(noteId, noteLockKey) : null;
if (!decrypted) {
lockedNotesSkipped++;
continue;
}
note = decrypted;
}
await queueExportItem(BaseModel.TYPE_NOTE, note);
exportedNoteIds.push(noteId);
const rids = await Note.linkedResourceIds(note.body);
// A locked note's body is ciphertext, so its resource ids come from the extracted list.
const rids = noteLockEnabled && NoteLockNote.isLocked(note)
? Note.unserializeExtractedResourceIds(note.extracted_resource_ids)
: await Note.linkedResourceIds(note.body);
resourceIds = resourceIds.concat(rids);
}
}
// Nothing would be written, so a single-file export would otherwise fail on the file it never wrote.
if (lockedNotesSkipped && !exportedNoteIds.length) {
throw new Error(_n('%d locked note could not be unlocked and was not exported.', '%d locked notes could not be unlocked and were not exported.', lockedNotesSkipped, lockedNotesSkipped));
}
resourceIds = ArrayUtils.unique(resourceIds);
for (let i = 0; i < resourceIds.length; i++) {
@@ -515,6 +558,8 @@ export default class InteropService {
options.onProgress?.(ExportProgressState.Closing, null);
await exporter.close();
if (lockedNotesSkipped) result.warnings.push(`${lockedNotesSkipped} locked note(s) could not be unlocked and were not exported`);
return result;
}
}
@@ -28,6 +28,8 @@ export default class InteropService_Exporter_Jex extends InteropService_Exporter
}
public async close() {
await this.rawExporter_.close();
const stats = await shim.fsDriver().readDirStats(this.tempDir_, { recursive: true });
const filePaths = stats.filter(a => !a.isDirectory()).map(a => a.path);
@@ -1,13 +1,18 @@
import InteropService_Exporter_Base from './InteropService_Exporter_Base';
import BaseItem from '../../models/BaseItem';
import BaseModel from '../../BaseModel';
import { basename } from '../../path-utils';
import shim from '../../shim';
import { BaseItemEntity, ResourceEntity } from '../database/types';
import { BaseItemEntity, NoteEntity, ResourceEntity } from '../database/types';
import EncryptionService from '../e2ee/EncryptionService';
import NoteLockNote from '../noteLock/NoteLockNote';
import NoteLockKey, { noteLockKeyFileName } from '../noteLock/NoteLockKey';
export default class InteropService_Exporter_Raw extends InteropService_Exporter_Base {
private destDir_: string;
private resourceDir_: string;
private hasLockedNotes_ = false;
public async init(destDir: string) {
this.destDir_ = destDir;
@@ -17,7 +22,21 @@ export default class InteropService_Exporter_Raw extends InteropService_Exporter
await shim.fsDriver().mkdir(this.resourceDir_);
}
// Not behind the feature flag: importers that predate note lock ignore the key file. Only the profile's key can be exported,
// so notes locked with another key do not bring it in.
private async isLockedWithProfileKey_(note: NoteEntity) {
if (!NoteLockNote.isLocked(note)) return false;
const keyId = NoteLockKey.instance().load()?.id;
if (!keyId) return false;
try {
return (await EncryptionService.instance().decodeHeaderString(note.body, true)).masterKeyId === keyId;
} catch {
return false;
}
}
public async processItem(itemType: number, item: BaseItemEntity) {
if (itemType === BaseModel.TYPE_NOTE && !this.hasLockedNotes_) this.hasLockedNotes_ = await this.isLockedWithProfileKey_(item as NoteEntity);
const ItemClass = BaseItem.getClassByItemType(itemType);
const serialized = await ItemClass.serialize(item);
const filePath = `${this.destDir_}/${ItemClass.systemPath(item)}`;
@@ -29,5 +48,11 @@ export default class InteropService_Exporter_Raw extends InteropService_Exporter
await shim.fsDriver().copy(filePath, destResourcePath);
}
public async close() {}
public async close() {
// The key travels with the backup, so a backup plus the password is always enough to recover.
if (this.hasLockedNotes_) {
const key = NoteLockKey.instance().load();
if (key) await shim.fsDriver().writeFile(`${this.destDir_}/${noteLockKeyFileName}`, JSON.stringify(key), 'utf-8');
}
}
}
@@ -12,8 +12,13 @@ const { sprintf } = require('sprintf-js');
import shim from '../../shim';
import { Stat } from '../../fs-driver-base';
import { ResourceEntity } from '../database/types';
import { MasterKeyEntity } from '../e2ee/types';
import { fileExtension } from '../../path-utils';
import uuid from '../../uuid';
import isNoteLockEnabled from '../noteLock/isNoteLockEnabled';
import NoteLockService from '../noteLock/NoteLockService';
import NoteLockSession from '../noteLock/NoteLockSession';
import NoteLockKey, { DecryptedNoteLockKey, noteLockKeyFileName } from '../noteLock/NoteLockKey';
export default class InteropService_Importer_Raw extends InteropService_Importer_Base {
public async exec(result: ImportExportResult) {
@@ -37,6 +42,24 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
const stats = await shim.fsDriver().readDirStats(this.sourcePath_);
// A backup with locked notes carries their encrypted key (see the raw exporter).
let importNoteLockKey: DecryptedNoteLockKey = null;
let undecryptableNotes = 0;
if (isNoteLockEnabled() && await shim.fsDriver().exists(`${this.sourcePath_}/${noteLockKeyFileName}`)) {
const keyFile: MasterKeyEntity = JSON.parse(await shim.fsDriver().readFile(`${this.sourcePath_}/${noteLockKeyFileName}`));
const profileKeyId = NoteLockKey.instance().load()?.id;
if (keyFile?.id && this.options_.onNoteLockKey) {
importNoteLockKey = await this.options_.onNoteLockKey(keyFile);
} else if (keyFile?.id && !profileKeyId) {
// Without a handler, as for the CLI, nothing can update the notes.
result.warnings.push('The locked notes in this backup cannot be read, because no note lock key has been set up on this profile');
} else if (keyFile?.id && keyFile.id !== profileKeyId) {
result.warnings.push('The locked notes in this backup have not been migrated to the current note lock key and will be unreadable');
}
}
// Captured once the prompts have unlocked the session, so locking it partway through does not fail the remaining notes.
const profileNoteLockKey = importNoteLockKey ? NoteLockSession.instance().decryptedKey() : null;
const folderExists = function(stats: Stat[], folderId: string) {
folderId = folderId.toLowerCase();
for (let i = 0; i < stats.length; i++) {
@@ -87,6 +110,7 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
const item = await BaseItem.unserialize(content);
const itemType = item.type_;
const ItemClass = BaseItem.itemClass(item);
let useNoteLockSave = false;
delete item.type_;
@@ -97,6 +121,26 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
item.id = itemIdMap[item.id];
item.parent_id = itemIdMap[item.parent_id];
item.body = await replaceLinkedItemIds(item.body);
if (isNoteLockEnabled() && item.is_locked) {
// The linked id rewrite cannot reach a ciphertext body, so remap the extracted
// list instead, keeping the imported resources safe from orphan cleanup.
item.extracted_resource_ids = Note.serializeExtractedResourceIds(Note.unserializeExtractedResourceIds(item.extracted_resource_ids).map(id => {
if (!itemIdMap[id]) itemIdMap[id] = uuid.create();
return itemIdMap[id];
}));
if (importNoteLockKey) {
try {
const plainBody = await NoteLockService.withDecryptedKey(scoped => scoped.decryptString(item.body), importNoteLockKey);
item.body = await replaceLinkedItemIds(plainBody);
// Marked like a gated load, so a save after the feature is turned off fails instead of writing it as plain text.
item.isDecrypted = true;
useNoteLockSave = true;
} catch {
undecryptableNotes++;
}
}
}
} else if (itemType === BaseModel.TYPE_FOLDER) {
if (destinationFolderId) continue;
@@ -138,7 +182,7 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
continue;
}
await ItemClass.save(item, { isNew: true, autoTimestamp: false });
await ItemClass.save(item, { isNew: true, autoTimestamp: false, useNoteLock: useNoteLockSave, noteLockKey: profileNoteLockKey });
} catch (error) {
if (error.code === 'malformedItem') {
result.warnings.push(sprintf('Skipped malformed item: %s: %s', stat.path, error.message));
@@ -149,6 +193,8 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
}
}
if (undecryptableNotes) result.warnings.push(`${undecryptableNotes} locked note(s) could not be unlocked with the provided key and were imported unchanged`);
for (let i = 0; i < noteTagsToCreate.length; i++) {
const noteTag = noteTagsToCreate[i];
const newNoteId = itemIdMap[noteTag.note_id];
+11
View File
@@ -1,5 +1,7 @@
import { FolderEntity } from '../database/types';
import { PluginStates } from '../plugins/reducer';
import { MasterKeyEntity } from '../e2ee/types';
import { DecryptedNoteLockKey } from '../noteLock/NoteLockKey';
export interface CustomImportContext {
sourcePath: string;
@@ -54,6 +56,10 @@ export interface ImportOptions {
xmlSerializer?: XMLSerializer;
defaultFolderTitle?: string;
// Called when a backup carries a note lock key. Return it decrypted to re-encrypt the imported
// locked notes for this profile, or null to import them unchanged.
onNoteLockKey?: (key: MasterKeyEntity)=> Promise<DecryptedNoteLockKey|null>;
}
export enum ExportProgressState {
@@ -76,6 +82,11 @@ export interface ExportOptions {
customCss?: string;
packIntoSingleFile?: boolean;
shouldEmbedOnlyImages?: boolean;
// The user chose to export without the locked notes, so they are left out without a warning.
skipLockedNotes?: boolean;
// Locked notes are left out with a warning unless this is set, so only an export the user confirmed writes them as
// plain text, and the export commands plugins can run do not.
decryptLockedNotes?: boolean;
onProgress?: OnExportProgressCallback;
}
+15 -3
View File
@@ -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';
@@ -8,6 +9,9 @@ export interface DecryptedNoteLockKey {
plainText: string;
}
// Importers that predate note lock skip this file, since they only read .md files.
export const noteLockKeyFileName = 'note_lock_key.json';
export default class NoteLockKey {
public static instance_: NoteLockKey = null;
@@ -39,6 +43,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 +62,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) {
@@ -77,8 +89,8 @@ export default class NoteLockKey {
return this.changePassword(password, password);
}
public async decrypt(password: string): Promise<DecryptedNoteLockKey> {
const key = this.load();
public async decrypt(password: string, key: MasterKeyEntity = null): Promise<DecryptedNoteLockKey> {
if (!key) key = this.load();
if (!key) throw new Error('Note lock key has not been created');
if (!key.id) throw new Error('Note lock key does not have an ID');
@@ -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');
}
}
@@ -0,0 +1,93 @@
import Setting from '../../models/Setting';
import shim from '../../shim';
import { encryptionService, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
import EncryptionService from '../e2ee/EncryptionService';
import NoteLockKey from './NoteLockKey';
import NoteLockService from './NoteLockService';
import NoteLockSession from './NoteLockSession';
import { promptForImportedNoteLockKey } from './noteLockPrompts';
import { ErrorCode } from '../../errors';
const makePrompts = (passwords: (string|null)[]) => ({
promptPassword: jest.fn(async () => passwords.shift() ?? null),
goToNoteLockSetup: jest.fn(),
});
describe('noteLockPrompts', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
Setting.setValue('featureFlag.noteLock', true);
shim.showErrorDialog = jest.fn(async () => {});
});
const otherBackupKey = async () => ({ ...await encryptionService().generateMasterKey('backup'), id: 'backup' });
// Answers the dialogs in order: the "update them?" question (0 Yes, 1 No, 2 Cancel), then the setup question (0 Yes, 1 No).
const answer = (...responses: number[]) => {
shim.showMessageBox = jest.fn(async () => responses.shift());
};
it('should import a backup of this profile as it is, without any prompt', async () => {
const key = await NoteLockKey.instance().create('local');
answer(0);
const prompts = makePrompts([]);
expect(await promptForImportedNoteLockKey(key, prompts)).toBeNull();
expect(shim.showMessageBox).not.toHaveBeenCalled();
expect(prompts.promptPassword).not.toHaveBeenCalled();
});
it('should unlock the session, then decrypt the key of a backup from another profile', async () => {
const backupKey = await otherBackupKey();
await NoteLockKey.instance().create('local');
answer(0);
const prompts = makePrompts(['local', 'wrong', 'backup']);
expect((await promptForImportedNoteLockKey(backupKey, prompts)).id).toBe(backupKey.id);
expect(NoteLockSession.instance().isUnlocked()).toBe(true);
expect(prompts.promptPassword).toHaveBeenNthCalledWith(1, 'Enter your current note lock password');
expect(shim.showErrorDialog).toHaveBeenCalledTimes(1);
});
it('should import unchanged when the user does not want to update the notes', async () => {
const backupKey = await otherBackupKey();
await NoteLockKey.instance().create('local');
answer(1);
const prompts = makePrompts([]);
expect(await promptForImportedNoteLockKey(backupKey, prompts)).toBeNull();
expect(prompts.promptPassword).not.toHaveBeenCalled();
});
it.each([
['the question', []],
['the current password', [null]],
['the backup password', ['local', null]],
])('should cancel the import when %s is cancelled', async (_step, passwords: (string|null)[]) => {
const backupKey = await otherBackupKey();
await NoteLockKey.instance().create('local');
answer(passwords.length ? 0 : 2);
await expect(promptForImportedNoteLockKey(backupKey, makePrompts(passwords))).rejects.toMatchObject({ code: ErrorCode.Cancelled });
});
it.each([
['opens the note lock setup', true],
['does not', false],
])('should cancel the import when no note lock key is set up and the user %s', async (_label, opensSetup) => {
const backupKey = await otherBackupKey();
answer(0, opensSetup ? 0 : 1);
const prompts = makePrompts([]);
await expect(promptForImportedNoteLockKey(backupKey, prompts)).rejects.toMatchObject({ code: ErrorCode.Cancelled });
expect(prompts.goToNoteLockSetup).toHaveBeenCalledTimes(opensSetup ? 1 : 0);
expect(prompts.promptPassword).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,69 @@
import { _ } from '../../locale';
import JoplinError from '../../JoplinError';
import { ErrorCode } from '../../errors';
import shim from '../../shim';
import { MasterKeyEntity } from '../e2ee/types';
import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
import NoteLockSession from './NoteLockSession';
// Shared by desktop and mobile, which each supply their own password prompt and settings navigation.
export interface NoteLockPrompts {
promptPassword: (label: string)=> Promise<string|null>;
goToNoteLockSetup: ()=> void;
}
const promptNoteLockSetup = async (prompts: NoteLockPrompts) => {
const answer = await shim.showMessageBox(_('A note lock key is required to update these notes, but hasn\'t been set up yet on your profile. Would you like to set one up now?'), {
buttons: [_('Yes'), _('No')],
cancelId: 1,
});
if (answer === 0) {
prompts.goToNoteLockSetup();
}
};
const cancelled = () => new JoplinError('Cancelled', ErrorCode.Cancelled);
// Returns false when the user cancels or has no note lock password to unlock with.
export const unlockNoteLockSession = async (prompts: NoteLockPrompts) => {
if (!NoteLockKey.instance().load()) {
await promptNoteLockSetup(prompts);
return false;
}
while (!NoteLockSession.instance().isUnlocked()) {
const password = await prompts.promptPassword(_('Enter your current note lock password'));
if (!password) return false;
try {
await NoteLockSession.instance().unlock(password);
} catch {
await shim.showErrorDialog(_('Invalid password'));
}
}
return true;
};
// Returns the backup's key decrypted, to re-encrypt its locked notes for this profile, or null to import them unchanged.
// Throws a cancelled error when the user cancels any step, so the whole import stops.
export const promptForImportedNoteLockKey = async (backupKey: MasterKeyEntity, prompts: NoteLockPrompts): Promise<DecryptedNoteLockKey|null> => {
// Already under the profile's key, so the notes need no update.
if (backupKey.id === NoteLockKey.instance().load()?.id) return null;
const answer = await shim.showMessageBox(_('This backup contains notes locked with a different key. They must be updated now to use your current key, or they\'ll remain inaccessible and cannot be updated later. Would you like to update them?'), {
buttons: [_('Yes'), _('No'), _('Cancel')],
cancelId: 2,
});
if (answer === 1) return null;
if (answer !== 0) throw cancelled();
if (!await unlockNoteLockSession(prompts)) throw cancelled();
while (true) {
const password = await prompts.promptPassword(_('Enter the note lock password the backup was created with'));
if (!password) throw cancelled();
try {
return await NoteLockKey.instance().decrypt(password, backupKey);
} catch {
await shim.showErrorDialog(_('Invalid password'));
}
}
};
@@ -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
+1 -1
View File
@@ -39,7 +39,7 @@
"fs-extra": "11.3.6",
"html-entities": "1.4.0",
"jquery": "3.7.1",
"knex": "3.2.10",
"knex": "3.3.0",
"koa": "2.16.4",
"ldapts": "8.1.8",
"markdown-it": "13.0.2",
@@ -1,5 +1,7 @@
import { expectThrow } from '../../utils/testing/testUtils';
import { expectHttpError, expectThrow } from '../../utils/testing/testUtils';
import { defaultPagination, Pagination, createPaginationLinks, requestPagination, PaginationOrderDir } from './pagination';
import { ErrorBadRequest } from '../../utils/errors';
import { encodeBase64 } from '../../utils/base64';
describe('pagination', () => {
@@ -69,6 +71,38 @@ describe('pagination', () => {
await expectThrow(async () => requestPagination({ page: 0 }));
});
test.each([
'title; DROP TABLE items',
'items.title',
'1',
'',
'title ASC',
'count(*)',
])('should reject an order_by that is not a column name: %s', async (orderBy) => {
// The field ends up as a column name in the query, so anything that
// could not be one must be reported as a bad request rather than
// reaching the database.
await expectHttpError(async () => requestPagination({ order_by: orderBy }), ErrorBadRequest.httpCode);
});
test.each([
['not base64 at all', 'Invalid cursor'],
[encodeBase64('{ not json'), 'Invalid cursor'],
[encodeBase64(JSON.stringify({ limit: -1, page: 1, order: [] })), 'Limit out of bond: -1'],
[encodeBase64(JSON.stringify({ limit: 10, page: 1, order: [{ by: 'title; DROP TABLE items', dir: 'asc' }] })), 'Invalid order_by'],
[encodeBase64(JSON.stringify({ limit: 10, page: 1 })), 'Invalid order parameter'],
])('should reject a malformed cursor: %s', async (cursor) => {
// The cursor is supplied by the client, so it cannot be trusted just
// because the server issued one earlier.
await expectHttpError(async () => requestPagination({ cursor }), ErrorBadRequest.httpCode);
});
test('should accept a cursor the server issued', async () => {
const cursor = encodeBase64(JSON.stringify(defaultPagination()));
const p = requestPagination({ cursor });
expect(p.page).toBe(defaultPagination().page + 1);
});
test('should create page link logic', async () => {
expect(createPaginationLinks(1, 5)).toEqual([
{ page: 1, isCurrent: true },
+33 -7
View File
@@ -39,6 +39,17 @@ export const pageMaxSize = 100;
const defaultOrderField_ = 'updated_time';
const defaultOrderDir_ = PaginationOrderDir.DESC;
// The order field ends up as a column name in the SQL query. Knex quotes
// identifiers, so this is not an injection vector, but an arbitrary string
// still reaches the database and comes back as a 500. Only accept something
// that could be a column name, and reject anything else as a bad request.
const orderFieldRegex_ = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const validateOrderField = (field: string) => {
if (typeof field !== 'string' || !orderFieldRegex_.test(field)) throw new ErrorBadRequest(`Invalid order_by parameter: ${field}`);
return field;
};
export function defaultPagination(): Pagination {
return {
limit: pageMaxSize,
@@ -66,26 +77,39 @@ export function requestPaginationOrder(query: ParsedUrlQuery | PaginationQueryPa
if (![PaginationOrderDir.ASC, PaginationOrderDir.DESC].includes(orderDir)) throw new ErrorBadRequest(`Invalid order_dir parameter: ${orderDir}`);
return [{
by: orderBy,
by: validateOrderField(orderBy),
dir: orderDir,
}];
}
export function validatePagination(p: Pagination): Pagination {
if (p.limit < 0 || p.limit > pageMaxSize) throw new ErrorBadRequest(`Limit out of bond: ${p.limit}`);
if (p.page <= 0) throw new ErrorBadRequest(`Invalid page number: ${p.page}`);
if (!p || typeof p !== 'object') throw new ErrorBadRequest('Invalid pagination');
if (!Number.isFinite(p.limit) || p.limit < 0 || p.limit > pageMaxSize) throw new ErrorBadRequest(`Limit out of bond: ${p.limit}`);
if (!Number.isFinite(p.page) || p.page <= 0) throw new ErrorBadRequest(`Invalid page number: ${p.page}`);
if (!Array.isArray(p.order)) throw new ErrorBadRequest('Invalid order parameter');
for (const o of p.order) {
if (![PaginationOrderDir.ASC, PaginationOrderDir.DESC].includes(o.dir)) throw new ErrorBadRequest(`Invalid order_dir parameter: ${o.dir}`);
validateOrderField(o.by);
}
return p;
}
function processCursor(pagination: Pagination): Pagination {
// If a cursor is present, we parse it and move to the next page.
// If a cursor is present, we parse it and move to the next page. The
// cursor comes from the client, so a malformed one must be reported as a
// bad request rather than fail while parsing.
if (pagination.cursor) {
const p = validatePagination(JSON.parse(decodeBase64(pagination.cursor)));
let decoded: Pagination = null;
try {
decoded = JSON.parse(decodeBase64(pagination.cursor));
} catch (error) {
throw new ErrorBadRequest('Invalid cursor');
}
const p = validatePagination(decoded);
p.page++;
return p;
}
@@ -100,9 +124,11 @@ export function requestPagination(query: (Pagination & PaginationQueryParams) |
return processCursor(query);
}
const limit = 'limit' in query ? query.limit : pageMaxSize;
// These come from the query string, so they are strings at runtime even
// though the type says otherwise.
const limit = 'limit' in query ? Number(query.limit) : pageMaxSize;
const order: PaginationOrder[] = requestPaginationOrder(query);
const page: number = 'page' in query ? query.page : 1;
const page: number = 'page' in query ? Number(query.page) : 1;
return validatePagination({ limit, order, page });
}
+1
View File
@@ -375,4 +375,5 @@ twodo
Gemma
untruncated
instr
mixmark
Hrvatski
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -59,7 +59,7 @@
"gulp": "4.0.2",
"html-entities": "1.4.0",
"jest": "29.7.0",
"js-yaml": "4.2.0",
"js-yaml": "4.3.0",
"rss": "1.2.2",
"sass": "1.101.7",
"sqlite3": "5.1.6",
+1 -1
View File
@@ -18,7 +18,7 @@
"dotenv": "17.4.2",
"file-type": "16.5.4",
"fs-extra": "11.3.6",
"knex": "3.2.10",
"knex": "3.3.0",
"koa": "2.16.4",
"koa-body": "6.0.1",
"pg-boss": "10.4.2",
+8 -4
View File
@@ -29,8 +29,11 @@ function getColumnAlignment(table, columnIndex) {
var align = '';
for (var i = 0; i < table.rows.length; ++i) {
var row = table.rows[i];
// Read `rows` once: under domino each access re-walks the whole table, which made converting large tables
// quadratic: https://github.com/laurent22/joplin/issues/16894
var rows = table.rows;
for (var i = 0; i < rows.length; ++i) {
var row = rows[i];
if (columnIndex < row.childNodes.length) {
var cellAlignment = getAlignment(row.childNodes[columnIndex]);
++votes[cellAlignment];
@@ -371,8 +374,9 @@ function handleColSpan(content, node, emptyChar) {
function tableColCount(node) {
let maxColCount = 0;
for (let i = 0; i < node.rows.length; i++) {
const row = node.rows[i]
const rows = node.rows;
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const colCount = row.childNodes.length
if (colCount > maxColCount) maxColCount = colCount
}
+38 -18
View File
@@ -12590,7 +12590,7 @@ __metadata:
md5: "npm:2.3.0"
moment: "npm:2.30.1"
mustache: "npm:4.2.0"
nan: "npm:2.27.0"
nan: "npm:2.28.0"
node-notifier: "npm:10.0.1"
node-rsa: "npm:1.1.1"
onnxruntime-node: "npm:1.27.0"
@@ -12951,7 +12951,7 @@ __metadata:
immer: "npm:9.0.21"
jest: "npm:29.7.0"
jest-expect-message: "npm:1.1.3"
js-yaml: "npm:4.2.0"
js-yaml: "npm:4.3.0"
jsdom: "npm:26.1.0"
markdown-it: "npm:13.0.2"
md5: "npm:2.3.0"
@@ -13170,7 +13170,7 @@ __metadata:
jest-expect-message: "npm:1.1.3"
jquery: "npm:3.7.1"
jsdom: "npm:26.1.0"
knex: "npm:3.2.10"
knex: "npm:3.3.0"
koa: "npm:2.16.4"
ldapts: "npm:8.1.8"
markdown-it: "npm:13.0.2"
@@ -13227,7 +13227,7 @@ __metadata:
gulp: "npm:4.0.2"
html-entities: "npm:1.4.0"
jest: "npm:29.7.0"
js-yaml: "npm:4.2.0"
js-yaml: "npm:4.3.0"
license-checker-rseidelsohn: "npm:4.4.2"
markdown-it: "npm:13.0.2"
md5-file: "npm:5.0.0"
@@ -13267,7 +13267,7 @@ __metadata:
gulp: "npm:4.0.2"
jest: "npm:29.7.0"
jest-expect-message: "npm:1.1.3"
knex: "npm:3.2.10"
knex: "npm:3.3.0"
koa: "npm:2.16.4"
koa-body: "npm:6.0.1"
pg-boss: "npm:10.4.2"
@@ -39285,14 +39285,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:4.2.0, js-yaml@npm:^4.1.1":
version: 4.2.0
resolution: "js-yaml@npm:4.2.0"
"js-yaml@npm:4.3.0":
version: 4.3.0
resolution: "js-yaml@npm:4.3.0"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.js
checksum: 10/51de2067a2b44b07ba5206132e56005f8b568ff279bb4d2f645068958c56fa4827d40a6841c983234671fa0a134bf094d0b0717873c2a3d319185297af145a6d
checksum: 10/2bcec3a8118d7f744badeb04e14366578d234a736f353d41fe35d2305e4ce2409a8e041d277f07cd6bbc8aaa12128d650a68ce43247072519bede20962d2126f
languageName: node
linkType: hard
@@ -39319,6 +39319,17 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:^4.1.1":
version: 4.2.0
resolution: "js-yaml@npm:4.2.0"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.js
checksum: 10/51de2067a2b44b07ba5206132e56005f8b568ff279bb4d2f645068958c56fa4827d40a6841c983234671fa0a134bf094d0b0717873c2a3d319185297af145a6d
languageName: node
linkType: hard
"jsbn@npm:~0.1.0":
version: 0.1.1
resolution: "jsbn@npm:0.1.1"
@@ -39895,9 +39906,9 @@ __metadata:
languageName: node
linkType: hard
"knex@npm:3.2.10":
version: 3.2.10
resolution: "knex@npm:3.2.10"
"knex@npm:3.3.0":
version: 3.3.0
resolution: "knex@npm:3.3.0"
dependencies:
colorette: "npm:2.0.19"
commander: "npm:^10.0.0"
@@ -39911,13 +39922,15 @@ __metadata:
pg-connection-string: "npm:2.6.2"
rechoir: "npm:^0.8.0"
resolve-from: "npm:^5.0.0"
tarn: "npm:^3.0.2"
tarn: "npm:^3.1.0"
tildify: "npm:2.0.0"
peerDependencies:
pg-query-stream: ^4.14.0
peerDependenciesMeta:
better-sqlite3:
optional: true
mariadb:
optional: true
mysql:
optional: true
mysql2:
@@ -39934,7 +39947,7 @@ __metadata:
optional: true
bin:
knex: bin/cli.js
checksum: 10/6d043d0d55bee2d6ecc977dd67fbff61121f8832d70a635f490a3979bf82cae001ee0b53a1d1222ef46c3f1bf5afac39b1fb918aaefabcc7a7a57d7c97e3cf7f
checksum: 10/40924546fb223d186dd76b5eea415b5eaee517b9ba1d8ddad689e5c68a0e4e08fffe8e69f9e9d31ca533c8b8c0244f7bb47bc75b308efb58bceccc16322ec11d
languageName: node
linkType: hard
@@ -44481,12 +44494,12 @@ __metadata:
languageName: node
linkType: hard
"nan@npm:2.27.0":
version: 2.27.0
resolution: "nan@npm:2.27.0"
"nan@npm:2.28.0":
version: 2.28.0
resolution: "nan@npm:2.28.0"
dependencies:
node-gyp: "npm:latest"
checksum: 10/bdce0630e417740501394c412bd9f0ed1c287825e3b8f9b7efb95cc3acd3ef69de60479b5f00a2d039b79321e5ce29b672b0b263cfe0e4d8f47c8f810a24a5ee
checksum: 10/299ffb18f43cc53b0728cb16ed15e868960168b84d4140b54c8e057c54e6714d0291eee63c02c3cae4bcf5d0d06b6a93fc8717d8d9ae72ad6d4e8eba5ddacc66
languageName: node
linkType: hard
@@ -56583,6 +56596,13 @@ __metadata:
languageName: node
linkType: hard
"tarn@npm:^3.1.0":
version: 3.1.2
resolution: "tarn@npm:3.1.2"
checksum: 10/7b8bb2f439abd867ac40ab72c2a1d8f6bf7825523c15189b71bcc2fe38b38a404319f0358cf32fa0d3c3b731eeff5429dfadae69eeb14eaf2903a4f04c0d7a18
languageName: node
linkType: hard
"tcompare@npm:^5.0.6, tcompare@npm:^5.0.7":
version: 5.0.7
resolution: "tcompare@npm:5.0.7"