feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -0,0 +1,100 @@
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';
export interface DecryptedNoteLockKey {
id: string;
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;
private constructor(private encryptionService_: EncryptionService = EncryptionService.instance()) {}
public static instance() {
if (!this.instance_) {
this.instance_ = new NoteLockKey();
}
return this.instance_;
}
public static destroyInstance() {
this.instance_ = null;
}
public load(): MasterKeyEntity {
return localSyncInfo().noteLockKey;
}
public save(o: MasterKeyEntity): MasterKeyEntity {
const key = { ...o };
if (!key.id) {
key.id = uuid.create();
key.created_time = Date.now();
}
key.updated_time = Date.now();
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;
}
public async create(password: string) {
if (this.load()) throw new Error('Note lock key already exists');
const key = await this.encryptionService_.generateMasterKey(password);
// A key may have arrived through sync while the new one was being generated - keep it,
// since notes may already be locked with it on another device.
if (this.load()) throw new Error('Note lock key already exists');
return this.save(key);
}
// 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) {
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) {
const key = this.load();
if (!key) throw new Error('Note lock key has not been created');
return this.save(await this.encryptionService_.reencryptMasterKey(key, currentPassword, newPassword));
}
public needsUpgrade() {
const key = this.load();
return !!key && !!this.encryptionService_.masterKeysThatNeedUpgrading([key]).length;
}
public async upgrade(password: string) {
const key = this.load();
if (!key) throw new Error('Note lock key has not been created');
if (!this.needsUpgrade()) return key;
return this.changePassword(password, password);
}
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');
const plainText = await this.encryptionService_.decryptMasterKeyContent(key, password);
return { id: key.id, plainText };
}
}

查看文件

@@ -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)}`;
};

查看文件

@@ -0,0 +1,49 @@
import { NoteEntity } from '../database/types';
import NoteLockService from './NoteLockService';
import type { DecryptedNoteLockKey } from './NoteLockKey';
// The marker a gated load stamps on a note. Not a database column, so it cannot live on the
// generated NoteEntity type.
export type NoteLockNoteEntity = NoteEntity & { isDecrypted?: boolean };
export default class NoteLockNote {
public static isLocked(note: NoteEntity): boolean {
if (!note) return false;
return !!note.is_locked;
}
public static isLocking(note: NoteEntity, oldNote: NoteEntity): boolean {
if (!oldNote) return false;
return this.isLocked(note) && !oldNote.is_locked;
}
public static async decryptBody(note: NoteEntity, key: DecryptedNoteLockKey = null): Promise<NoteLockNoteEntity> {
if (!note) throw new Error('Gated note lock load is missing note');
if (note.is_locked === undefined) throw new Error('Gated note lock load is missing lock state');
const isLocked = this.isLocked(note);
// The marker means the note was loaded with the gate, locked or not.
const result = { ...note, isDecrypted: true };
if (isLocked) {
// A missing body here means the gated load did not request enough fields, so pass an empty string and let decryption fail explicitly.
result.body = await NoteLockService.withDecryptedKey(scoped => scoped.decryptString(note.body ?? ''), key);
}
return result;
}
public static async prepareForSave(note: NoteLockNoteEntity, linkedItemIds: (body: string)=> string[], serializeResourceIds: (resourceIds: string[])=> string, isNew: boolean, useNoteLock: boolean, key: DecryptedNoteLockKey = null) {
if (!note) throw new Error('Gated note lock save is missing note');
if (!('body' in note)) throw new Error('Gated note lock save is missing body');
const isLocked = this.isLocked(note);
// The marker proves the data came from a gated load, so a cipher body is never encrypted
// a second time and a missed gated load fails during development, locked note or not.
if (useNoteLock && !isNew && !note.isDecrypted) throw new Error('Gated note lock save is missing decrypted state');
if (!isLocked) note.extracted_resource_ids = '';
const plainTextBody = note.body ?? '';
if (isLocked) {
note.extracted_resource_ids = serializeResourceIds(linkedItemIds(plainTextBody));
note.body = await NoteLockService.withDecryptedKey(scoped => scoped.encryptString(plainTextBody), key);
}
}
}

查看文件

@@ -0,0 +1,219 @@
import Setting from '../../models/Setting';
import { afterAllCleanUp, encryptionService, fileContentEqual, setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../testing/test-utils';
import EncryptionService, { EncryptionMethod } from '../e2ee/EncryptionService';
import { localSyncInfo, saveLocalSyncInfo } from '../synchronizer/syncInfoUtils';
import NoteLockKey from './NoteLockKey';
import NoteLockSession from './NoteLockSession';
import NoteLockService, { ScopedNoteLockService } from './NoteLockService';
const unlockedSession = async (password = '123456') => {
const session = NoteLockSession.instance();
await NoteLockKey.instance().create(password);
await session.unlock(password);
return session;
};
const changeSyncedKeyId = (id: string) => {
const syncInfo = localSyncInfo();
syncInfo.noteLockKey = { ...syncInfo.noteLockKey, id };
saveLocalSyncInfo(syncInfo);
};
describe('NoteLockService', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
});
afterAll(async () => {
await afterAllCleanUp();
});
it('should encrypt and decrypt strings and files without loading an E2EE master key', async () => {
const encryptionServiceInstance = EncryptionService.instance();
const noteLockKey = NoteLockKey.instance();
const session = NoteLockSession.instance();
const service = NoteLockService.instance();
await noteLockKey.create('123456');
await session.unlock('123456');
const cipherText = await service.encryptString('some secret');
// Note lock data carries its own header prefix so a JED prefix check cannot mistake it
// for sync E2EE data.
expect(cipherText.startsWith('JLD01')).toBe(true);
expect(await service.decryptString(cipherText)).toBe('some secret');
const sourcePath = `${supportDir}/photo.jpg`;
const encryptedPath = `${Setting.value('tempDir')}/note-lock-photo.crypted`;
const decryptedPath = `${Setting.value('tempDir')}/note-lock-photo.jpg`;
await service.encryptFile(sourcePath, encryptedPath);
await service.decryptFile(encryptedPath, decryptedPath);
expect(fileContentEqual(sourcePath, encryptedPath)).toBe(false);
expect(fileContentEqual(sourcePath, decryptedPath)).toBe(true);
expect(encryptionServiceInstance.loadedMasterKeysCount()).toBe(0);
});
it('should refuse to run a held operation while the session is locked', async () => {
await NoteLockKey.instance().create('123456');
await expect(NoteLockService.withDecryptedKey(async () => {})).rejects.toThrow('Note lock session is locked');
});
it('should change the note lock password without rotating the key', async () => {
const noteLockKey = NoteLockKey.instance();
const session = NoteLockSession.instance();
const service = NoteLockService.instance();
const firstKey = await noteLockKey.create('123456');
await session.unlock('123456');
const cipherText = await service.encryptString('some secret');
const changedKey = await noteLockKey.changePassword('123456', '654321');
expect(changedKey.id).toBe(firstKey.id);
expect(changedKey.content).not.toBe(firstKey.content);
// Same key id and decrypted content, so an already-unlocked session keeps working.
expect(session.isUnlocked()).toBe(true);
expect(await service.decryptString(cipherText)).toBe('some secret');
session.lock();
await expect(session.unlock('123456')).rejects.toThrow();
await session.unlock('654321');
expect(await service.decryptString(cipherText)).toBe('some secret');
});
it('should upgrade the note lock key without rotating it', async () => {
const noteLockKey = NoteLockKey.instance();
const session = NoteLockSession.instance();
const service = NoteLockService.instance();
const oldKey = noteLockKey.save(await EncryptionService.instance().generateMasterKey('123456', {
encryptionMethod: EncryptionMethod.SJCL2,
}));
await session.unlock('123456');
const cipherText = await service.encryptString('some secret');
expect(noteLockKey.needsUpgrade()).toBe(true);
const upgradedKey = await noteLockKey.upgrade('123456');
expect(upgradedKey.id).toBe(oldKey.id);
expect(upgradedKey.content).not.toBe(oldKey.content);
expect(noteLockKey.needsUpgrade()).toBe(false);
expect(await service.decryptString(cipherText)).toBe('some secret');
});
it('should keep a held operation working after the session locks, refuse the session-backed service, and revoke the scoped service afterwards', async () => {
const service = NoteLockService.instance();
const session = await unlockedSession();
const cipherText = await service.encryptString('some secret');
let escaped: ScopedNoteLockService = null;
await NoteLockService.withDecryptedKey(async scoped => {
escaped = scoped;
session.lock();
await expect(service.decryptString(cipherText)).rejects.toThrow('Note lock session is locked');
expect(await scoped.decryptString(cipherText)).toBe('some secret');
const lockedEncryptedText = await scoped.encryptString('encrypted while locked');
expect(await scoped.decryptString(lockedEncryptedText)).toBe('encrypted while locked');
});
await expect(service.decryptString(cipherText)).rejects.toThrow('Note lock session is locked');
await expect(escaped.decryptString(cipherText)).rejects.toThrow('Note lock operation key is no longer available');
});
it('should keep a held operation on its captured key when the synced key changes, and fail the session-backed service closed', async () => {
const service = NoteLockService.instance();
await unlockedSession();
const cipherText = await service.encryptString('some secret');
await NoteLockService.withDecryptedKey(async scoped => {
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
expect(await scoped.decryptString(cipherText)).toBe('some secret');
await expect(service.encryptString('unrelated')).rejects.toThrow('Note lock session is locked');
await expect(scoped.encryptString('unrelated')).rejects.toThrow('Note lock key changed during operation');
});
});
it('should fail a held encrypt closed while a reset is in progress', async () => {
const session = await unlockedSession();
await NoteLockService.withDecryptedKey(async scoped => {
// Don't await yet: reset() sets the rotating flag synchronously before its first await, which is
// what the held encrypt below needs to observe.
const resetting = session.reset('654321');
await expect(scoped.encryptString('some secret')).rejects.toThrow('Note lock key changed during operation');
await resetting;
});
});
it('should fail a held encryptFile closed and remove its output if the key rotates mid-encrypt', async () => {
await unlockedSession();
const sourcePath = `${supportDir}/photo.jpg`;
const destPath = `${Setting.value('tempDir')}/note-lock-rotate-mid-encrypt.crypted`;
const fsDriver = EncryptionService.instance().fsDriver();
await NoteLockService.withDecryptedKey(async scoped => {
// Don't await yet: encryptFile() doesn't reach its own first await until after the pre-check, so
// this mutation is guaranteed to land before the post-check below runs.
const encrypting = scoped.encryptFile(sourcePath, destPath);
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
await expect(encrypting).rejects.toThrow('Note lock key changed during operation');
});
expect(await fsDriver.exists(destPath)).toBe(false);
});
it('should fail the live service closed and remove its output if the key rotates mid-encrypt', async () => {
await unlockedSession();
const service = NoteLockService.instance();
const sourcePath = `${supportDir}/photo.jpg`;
const destPath = `${Setting.value('tempDir')}/note-lock-rotate-mid-encrypt-live.crypted`;
const fsDriver = EncryptionService.instance().fsDriver();
// Don't await yet: encryptFile() doesn't reach its own first await until after the pre-check, so this
// mutation is guaranteed to land before the post-check below runs.
const encrypting = service.encryptFile(sourcePath, destPath);
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
await expect(encrypting).rejects.toThrow('Note lock key changed during operation');
expect(await fsDriver.exists(destPath)).toBe(false);
});
it('should propagate errors from a held operation and still revoke the scoped service', async () => {
const session = NoteLockSession.instance();
await NoteLockKey.instance().create('123456');
await session.unlock('123456');
let escaped: ScopedNoteLockService = null;
await expect(NoteLockService.withDecryptedKey(async scoped => {
escaped = scoped;
throw new Error('boom');
})).rejects.toThrow('boom');
await expect(escaped.decryptString('does not matter')).rejects.toThrow('Note lock operation key is no longer available');
});
it('should not overwrite a key that arrives through sync while create is generating', async () => {
const encryptionServiceInstance = EncryptionService.instance();
const realGenerate = encryptionServiceInstance.generateMasterKey.bind(encryptionServiceInstance);
const spy = jest.spyOn(encryptionServiceInstance, 'generateMasterKey').mockImplementation(async (password: string) => {
const generated = await realGenerate(password);
const syncInfo = localSyncInfo();
syncInfo.noteLockKey = { id: 'synced-key' };
saveLocalSyncInfo(syncInfo);
return generated;
});
try {
await expect(NoteLockKey.instance().create('123456')).rejects.toThrow('Note lock key already exists');
expect(NoteLockKey.instance().load().id).toBe('synced-key');
} finally {
spy.mockRestore();
}
});
});

查看文件

@@ -0,0 +1,88 @@
import EncryptionService, { EncryptOptions } from '../e2ee/EncryptionService';
import { DecryptedNoteLockKey } from './NoteLockKey';
import NoteLockSession from './NoteLockSession';
export default class NoteLockService {
public static instance_: NoteLockService = null;
private constructor(
private encryptionService_: EncryptionService,
private keySource_: ()=> DecryptedNoteLockKey,
private assertCanEncrypt_: (keyId: string)=> void,
) {}
public static instance() {
if (!this.instance_) {
const session = NoteLockSession.instance();
this.instance_ = new NoteLockService(EncryptionService.instance(), () => session.decryptedKey(), keyId => session.assertCanEncryptWith(keyId));
}
return this.instance_;
}
// A passed key keeps working after a session lock, but encrypting with a rotated key still
// aborts via the session check. Callers must await every scoped op: one that's started but
// not awaited still holds its captured key copy.
public static async withDecryptedKey<T>(callback: (service: ScopedNoteLockService)=> Promise<T>, key: DecryptedNoteLockKey = null) {
const session = NoteLockSession.instance();
if (!key) key = session.decryptedKey();
const scoped = new NoteLockService(EncryptionService.instance(), () => key, keyId => session.assertCanEncryptWith(keyId));
const scopedView: ScopedNoteLockService = {
decryptString: cipherText => scoped.decryptString(cipherText),
decryptFile: (srcPath, destPath) => scoped.decryptFile(srcPath, destPath),
encryptString: plainText => scoped.encryptString(plainText),
encryptFile: (srcPath, destPath) => scoped.encryptFile(srcPath, destPath),
};
try {
return await callback(scopedView);
} finally {
scoped.revoke_();
}
}
public static destroyInstance() {
this.instance_ = null;
}
private revoke_() {
this.keySource_ = () => { throw new Error('Note lock operation key is no longer available'); };
}
public async encryptString(plainText: string) {
const key = this.keySource_();
this.assertCanEncrypt_(key.id);
const cipherText = await this.encryptionService_.encryptString(plainText, this.encryptionOptions_(key));
this.assertCanEncrypt_(key.id);
return cipherText;
}
public async decryptString(cipherText: string) {
return this.encryptionService_.decryptString(cipherText, this.encryptionOptions_(this.keySource_()));
}
public async encryptFile(srcPath: string, destPath: string) {
const key = this.keySource_();
this.assertCanEncrypt_(key.id);
await this.encryptionService_.encryptFile(srcPath, destPath, this.encryptionOptions_(key));
try {
this.assertCanEncrypt_(key.id);
} catch (error) {
await this.encryptionService_.fsDriver().unlink(destPath);
throw error;
}
}
public async decryptFile(srcPath: string, destPath: string) {
return this.encryptionService_.decryptFile(srcPath, destPath, this.encryptionOptions_(this.keySource_()));
}
private encryptionOptions_(key: DecryptedNoteLockKey): EncryptOptions {
return {
masterKeyId: key.id,
decryptedMasterKey: key.plainText,
isNoteLock: true,
};
}
}
export type ScopedNoteLockService = Pick<NoteLockService, 'encryptString' | 'encryptFile' | 'decryptString' | 'decryptFile'>;

查看文件

@@ -0,0 +1,259 @@
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';
import NoteLockSession from './NoteLockSession';
import NoteLockService from './NoteLockService';
import eventManager, { EventName, NoteLockSessionChangeEvent } from '../../eventManager';
const unlockedSession = async (password = '123456') => {
const session = NoteLockSession.instance();
await NoteLockKey.instance().create(password);
await session.unlock(password);
return session;
};
const changeSyncedKeyId = (id: string) => {
const syncInfo = localSyncInfo();
syncInfo.noteLockKey = { ...syncInfo.noteLockKey, id };
saveLocalSyncInfo(syncInfo);
};
// Gates NoteLockKey.decrypt() so a lock, reset or teardown can be forced to land while an unlock is
// still awaiting. Returns a function that lets the decryption resolve.
const gateDecrypt = (result?: DecryptedNoteLockKey) => {
let release: ()=> void = () => {};
const gate = new Promise<void>(resolve => { release = resolve; });
const key = NoteLockKey.instance();
const realDecrypt = key.decrypt.bind(key);
jest.spyOn(key, 'decrypt').mockImplementation(async password => {
await gate;
return result ?? realDecrypt(password);
});
return release;
};
// Gates generateMasterKey() so a reset can be held mid-rotation, with the old key still persisted,
// while another unlock or reset races it. Returns a function that lets the rotation resolve.
const gateKeyGeneration = () => {
let release: ()=> void = () => {};
const gate = new Promise<void>(resolve => { release = resolve; });
const encryption = EncryptionService.instance();
const realGenerate = encryption.generateMasterKey.bind(encryption);
jest.spyOn(encryption, 'generateMasterKey').mockImplementation(async (password, options) => {
await gate;
return realGenerate(password, options);
});
return release;
};
describe('NoteLockSession', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(2);
await switchClient(1);
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
});
afterAll(async () => {
await afterAllCleanUp();
});
it('should stay locked after create and reset until explicitly unlocked', async () => {
const session = NoteLockSession.instance();
const firstKey = await NoteLockKey.instance().create('123456');
expect(session.isUnlocked()).toBe(false);
await session.unlock('123456');
expect(session.isUnlocked()).toBe(true);
const secondKey = await session.reset('654321');
expect(secondKey.id).not.toBe(firstKey.id);
expect(session.isUnlocked()).toBe(false);
await session.unlock('654321');
expect(session.isUnlocked()).toBe(true);
});
it('should reject an incorrect password and stay locked', async () => {
const session = NoteLockSession.instance();
await NoteLockKey.instance().create('123456');
await expect(session.unlock('wrong password')).rejects.toThrow();
expect(session.isUnlocked()).toBe(false);
});
it('should lock and clear the key', async () => {
const session = await unlockedSession();
session.lock();
expect(session.isUnlocked()).toBe(false);
expect(() => session.decryptedKey()).toThrow('Note lock session is locked');
});
it('should lock when the synced key changes', async () => {
const session = await unlockedSession();
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
expect(session.isUnlocked()).toBe(false);
});
it('should stay locked when locked while an unlock is in flight', async () => {
const session = NoteLockSession.instance();
await NoteLockKey.instance().create('123456');
const release = gateDecrypt();
const unlocking = session.unlock('123456');
session.lock();
release();
await expect(unlocking).rejects.toThrow('locked while unlocking');
expect(session.isUnlocked()).toBe(false);
});
it('should not repopulate the key when destroyed mid-unlock', async () => {
const session = NoteLockSession.instance();
await NoteLockKey.instance().create('123456');
const release = gateDecrypt();
const unlocking = session.unlock('123456');
NoteLockSession.destroyInstance();
release();
await expect(unlocking).rejects.toThrow('locked while unlocking');
expect(session.isUnlocked()).toBe(false);
});
it('should not install a key that was replaced by sync while unlocking', async () => {
const session = NoteLockSession.instance();
const originalKey = await NoteLockKey.instance().create('123456');
// Generation is unchanged here, but the loaded id no longer matches the decrypted one.
const release = gateDecrypt({ id: originalKey.id, plainText: 'plaintext' });
const unlocking = session.unlock('123456');
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
release();
await expect(unlocking).rejects.toThrow('key changed while unlocking');
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');
const release = gateDecrypt({ id: originalKey.id, plainText: 'plaintext' });
const unlocking = session.unlock('123456');
await session.reset('654321');
release();
await expect(unlocking).rejects.toThrow('locked while unlocking');
expect(session.isUnlocked()).toBe(false);
});
it('should reject an old-password unlock that races a reset mid-rotation', async () => {
const session = await unlockedSession();
const releaseRotation = gateKeyGeneration();
const resetting = session.reset('654321');
await expect(session.unlock('123456')).rejects.toThrow('reset is in progress');
releaseRotation();
await resetting;
expect(session.isUnlocked()).toBe(false);
});
it('should reject a reset that overlaps another rotation', async () => {
const session = await unlockedSession();
const releaseRotation = gateKeyGeneration();
const firstReset = session.reset('654321');
await expect(session.reset('abcdef')).rejects.toThrow('A note lock key reset is already in progress');
releaseRotation();
await firstReset;
expect(session.isUnlocked()).toBe(false);
});
it('should emit a session change event on unlock and on an effective lock only', async () => {
const events: NoteLockSessionChangeEvent[] = [];
const listener = (event: NoteLockSessionChangeEvent) => events.push(event);
eventManager.on(EventName.NoteLockSessionChange, listener);
try {
const session = NoteLockSession.instance();
await NoteLockKey.instance().create('123456');
session.lock();
expect(events).toEqual([]);
await session.unlock('123456');
expect(events).toEqual([{ unlocked: true }]);
session.lock();
session.lock();
expect(events).toEqual([{ unlocked: true }, { unlocked: false }]);
await session.unlock('123456');
await session.reset('654321');
expect(events.slice(2)).toEqual([{ unlocked: true }, { unlocked: false }]);
await session.unlock('654321');
NoteLockSession.destroyInstance();
expect(events.slice(4)).toEqual([{ unlocked: true }, { unlocked: false }]);
} finally {
eventManager.off(EventName.NoteLockSessionChange, listener);
}
});
it('should emit a lock event when the synced key changes', async () => {
const session = await unlockedSession();
const events: NoteLockSessionChangeEvent[] = [];
const listener = (event: NoteLockSessionChangeEvent) => events.push(event);
eventManager.on(EventName.NoteLockSessionChange, listener);
try {
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
expect(session.isUnlocked()).toBe(false);
expect(events).toEqual([{ unlocked: false }]);
} finally {
eventManager.off(EventName.NoteLockSessionChange, listener);
}
});
it('should sync the encrypted note lock key without auto-unlocking or loading it into the E2EE registry', async () => {
const createdKey = await NoteLockKey.instance().create('123456');
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
await synchronizerStart();
await switchClient(2);
EncryptionService.instance_ = encryptionService();
await synchronizerStart();
expect(NoteLockKey.instance().load()).toEqual(createdKey);
expect(NoteLockSession.instance().isUnlocked()).toBe(false);
expect(EncryptionService.instance().loadedMasterKeysCount()).toBe(0);
});
});

查看文件

@@ -0,0 +1,99 @@
import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
import eventManager, { EventName } from '../../eventManager';
import { noteLockKeyConflict } from '../synchronizer/syncInfoUtils';
import { _ } from '../../locale';
export default class NoteLockSession {
public static instance_: NoteLockSession = null;
private key_: DecryptedNoteLockKey = null;
private lockGeneration_ = 0;
private rotating_ = false;
private migrating_ = false;
private constructor(private noteLockKey_: NoteLockKey = NoteLockKey.instance()) {}
public static instance() {
if (!this.instance_) {
this.instance_ = new NoteLockSession();
}
return this.instance_;
}
public static destroyInstance() {
// lock() bumps the generation so a mid-decrypt unlock() cannot repopulate a torn-down session.
this.instance_?.lock();
this.instance_ = null;
}
// Re-checks state after the await so a lock, reset, synced change or teardown that raced in
// can't install a stale key. A reset that starts mid-decrypt also locks, so the generation
// 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');
if (this.noteLockKey_.load()?.id !== decrypted.id) throw new Error('Cannot unlock: the note lock key changed while unlocking');
this.key_ = decrypted;
eventManager.emit(EventName.NoteLockSessionChange, { unlocked: true });
}
public lock() {
this.lockGeneration_++;
const wasUnlocked = !!this.key_;
this.key_ = null;
if (wasUnlocked) eventManager.emit(EventName.NoteLockSessionChange, { unlocked: false });
}
// 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 {
return await this.noteLockKey_.reset(password);
} finally {
this.lock();
this.rotating_ = false;
}
}
// 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();
}
public isUnlocked() {
this.lockIfKeyChanged_();
return !!this.key_;
}
public decryptedKey(): DecryptedNoteLockKey {
this.lockIfKeyChanged_();
if (!this.key_) throw new Error('Note lock session is locked');
return {
id: this.key_.id,
plainText: this.key_.plainText,
};
}
// 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 && noteLockKeyConflict()?.noteLockKey.id !== keyId)) throw new Error('Note lock key changed during operation');
}
}

查看文件

@@ -0,0 +1,13 @@
import { ModelType } from '../../BaseModel';
import BaseItem from '../../models/BaseItem';
import { NoteEntity } from '../database/types';
import NoteLockSession from './NoteLockSession';
import isNoteLockEnabled from './isNoteLockEnabled';
const hasLockedNoteWhileSessionLocked = async (noteIds: string[]) => {
if (!isNoteLockEnabled() || NoteLockSession.instance().isUnlocked()) return false;
const notes: NoteEntity[] = await BaseItem.loadItemsByTypeAndIds(ModelType.Note, noteIds, { fields: ['is_locked'] });
return notes.some(note => !!note.is_locked);
};
export default hasLockedNoteWhileSessionLocked;

查看文件

@@ -0,0 +1,7 @@
import Setting from '../../models/Setting';
const isNoteLockEnabled = () => {
return Setting.value('featureFlag.noteLock');
};
export default isNoteLockEnabled;

查看文件

@@ -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'));
}
}
};

查看文件

@@ -0,0 +1,104 @@
import Setting from '../../models/Setting';
import Note from '../../models/Note';
import Folder from '../../models/Folder';
import { encryptionService, setupDatabaseAndSynchronizer, switchClient, afterAllCleanUp } from '../../testing/test-utils';
import EncryptionService from '../e2ee/EncryptionService';
import NoteLockKey from './NoteLockKey';
import NoteLockService from './NoteLockService';
import NoteLockSession from './NoteLockSession';
import { disableNoteLock, enableNoteLock } from './setNoteLockState';
import eventManager, { EventName } from '../../eventManager';
const setUpUnlockedSession = async (password = '123456') => {
await NoteLockKey.instance().create(password);
await NoteLockSession.instance().unlock(password);
};
describe('setNoteLockState', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
Setting.setValue('featureFlag.noteLock', true);
});
afterAll(async () => {
await afterAllCleanUp();
});
it('should validate and emit the state change without saving the note', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
const events: unknown[] = [];
const listener = (event: unknown) => events.push(event);
eventManager.on(EventName.NoteLockNoteStateChange, listener);
try {
await enableNoteLock(note.id);
expect(events).toEqual([{ noteId: note.id, isLocked: true }]);
// The note screen persists the change through its scheduled gated save.
expect((await Note.load(note.id)).is_locked).toBe(0);
expect((await Note.load(note.id)).body).toBe('secret');
await Note.save({ ...await Note.load(note.id, { useNoteLock: true }), is_locked: 1 }, { useNoteLock: true });
await disableNoteLock(note.id);
expect(events[1]).toEqual({ noteId: note.id, isLocked: false });
expect((await Note.load(note.id)).is_locked).toBe(1);
} finally {
eventManager.off(EventName.NoteLockNoteStateChange, listener);
}
});
it('should throw when the note is already in the requested state', async () => {
await setUpUnlockedSession();
const note = await Note.save({ title: 'note', body: 'plain' });
await expect(disableNoteLock(note.id)).rejects.toThrow('not locked');
await Note.save({ ...await Note.load(note.id, { useNoteLock: true }), is_locked: 1 }, { useNoteLock: true });
await expect(enableNoteLock(note.id)).rejects.toThrow('already locked');
});
it('should fail closed when the session is locked', 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 });
NoteLockSession.instance().lock();
await expect(enableNoteLock(note.id)).rejects.toThrow();
await expect(disableNoteLock(lockedNote.id)).rejects.toThrow();
});
test.each([
['deleted note', { deleted_time: 1 }],
['conflict note', { is_conflict: 1 }],
])('should refuse to change the lock state of a %s', async (_label, fields) => {
await setUpUnlockedSession();
const note = await Note.save({ title: 'note', body: 'secret', ...fields });
await expect(enableNoteLock(note.id)).rejects.toThrow();
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' });
Setting.setValue('featureFlag.noteLock', false);
await expect(enableNoteLock(note.id)).rejects.toThrow();
});
});

查看文件

@@ -0,0 +1,45 @@
import { ModelType } from '../../BaseModel';
import BaseItem from '../../models/BaseItem';
import ItemChange from '../../models/ItemChange';
import Note from '../../models/Note';
import Setting from '../../models/Setting';
import { itemIsReadOnlySync, ItemSlice } from '../../models/utils/readOnly';
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.
const checkCanChangeLockState = (note: NoteEntity, noteId: string) => {
if (!isNoteLockEnabled()) throw new Error('Note lock is not enabled');
if (!note) throw new Error(`No such note: ${noteId}`);
if (note.deleted_time) throw new Error('Cannot change the note lock of a deleted note');
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'];
// These only validate and emit: the note screen listens for the event and persists the
// change with a scheduled gated save.
export const enableNoteLock = async (noteId: string) => {
const note = await Note.load(noteId, { fields: validationFields });
checkCanChangeLockState(note, noteId);
if (note.is_locked) throw new Error(`Note is already locked: ${noteId}`);
eventManager.emit(EventName.NoteLockNoteStateChange, { noteId, isLocked: true });
};
export const disableNoteLock = async (noteId: string) => {
const note = await Note.load(noteId, { fields: validationFields });
checkCanChangeLockState(note, noteId);
if (!note.is_locked) throw new Error(`Note is not locked: ${noteId}`);
eventManager.emit(EventName.NoteLockNoteStateChange, { noteId, isLocked: false });
};