Skip to content

Commit f746868

Browse files
committed
Export the note lock key whenever a note uses it and warn when an import cannot update locked notes
1 parent 4590040 commit f746868

4 files changed

Lines changed: 75 additions & 13 deletions

File tree

‎packages/lib/services/e2ee/EncryptionService.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -810,9 +810,9 @@ export default class EncryptionService {
810810
return `${isNoteLock ? 'JLD' : 'JED'}01${encryptionMetadata}`;
811811
}
812812

813-
public async decodeHeaderString(cipherText: string) {
813+
public async decodeHeaderString(cipherText: string, isNoteLock = false) {
814814
const source = this.stringReader_(cipherText);
815-
return this.decodeHeaderSource_(source);
815+
return this.decodeHeaderSource_(source, isNoteLock);
816816
}
817817

818818
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See encryptAbstract_ source/destination

‎packages/lib/services/interop/InteropService.noteLock.test.ts‎

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -183,13 +183,38 @@ describe('InteropService.noteLock', () => {
183183
expect(result.warnings).toEqual(['1 locked note(s) could not be unlocked and were not exported']);
184184
});
185185

186-
it.each([
187-
{ label: 'no note is locked', flagEnabled: true, isLocked: 0 },
188-
{ label: 'note lock is disabled', flagEnabled: false, isLocked: 1 },
189-
])('should not write the key file when $label', async ({ flagEnabled, isLocked }) => {
190-
Setting.setValue('featureFlag.noteLock', flagEnabled);
186+
it('should not write the key file when no note is locked', async () => {
187+
await setUpUnlockedSession();
188+
const folder = await Folder.save({ title: 'folder' });
189+
await Note.save({ title: 'note', body: 'plain body', parent_id: folder.id });
190+
191+
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
192+
193+
expect(await fs.pathExists(`${exportDir()}/${noteLockKeyFileName}`)).toBe(false);
194+
});
195+
196+
it('should write the key file for a note locked with the profile key even with note lock disabled', async () => {
197+
await setUpUnlockedSession();
198+
const folder = await Folder.save({ title: 'folder' });
199+
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
200+
await lockNote(note.id);
201+
202+
Setting.setValue('featureFlag.noteLock', false);
203+
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
204+
205+
expect(JSON.parse(await fs.readFile(`${exportDir()}/${noteLockKeyFileName}`, 'utf-8')).id).toBe(NoteLockKey.instance().load().id);
206+
});
207+
208+
it('should not write the key file when the locked notes use another key', async () => {
209+
await setUpUnlockedSession();
210+
const otherKey = await encryptionService().generateMasterKey('other');
211+
const body = await encryptionService().encryptString('secret', {
212+
masterKeyId: '0123456789abcdef0123456789abcdef',
213+
decryptedMasterKey: await encryptionService().decryptMasterKeyContent(otherKey, 'other'),
214+
isNoteLock: true,
215+
});
191216
const folder = await Folder.save({ title: 'folder' });
192-
await Note.save({ title: 'note', body: 'plain body', parent_id: folder.id, is_locked: isLocked });
217+
await Note.save({ title: 'note', body, parent_id: folder.id, is_locked: 1 });
193218

194219
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
195220

@@ -204,11 +229,12 @@ describe('InteropService.noteLock', () => {
204229
await lockNote(note.id);
205230
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
206231

207-
await InteropService.instance().import({ path: exportDir(), format: 'raw' });
232+
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
208233

209234
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
210235
expect(imported.body).not.toContain('secret');
211236
expect((await Note.load(imported.id, { useNoteLock: true })).body).toContain('secret');
237+
expect(result.warnings).toEqual([]);
212238

213239
// The extracted list follows the remapped resource id, so the resource stays associated.
214240
const importedResourceIds = Note.unserializeExtractedResourceIds(imported.extracted_resource_ids);
@@ -300,11 +326,29 @@ describe('InteropService.noteLock', () => {
300326

301327
await rotateProfileKey('new password');
302328

303-
await InteropService.instance().import({ path: exportDir(), format: 'raw' });
329+
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
304330

305331
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
306332
expect(imported.body).not.toContain('secret');
307333
await expect(Note.load(imported.id, { useNoteLock: true })).rejects.toThrow();
334+
expect(result.warnings).toEqual(['The locked notes in this backup have not been migrated to the current note lock key and will be unreadable']);
335+
});
336+
337+
it('should warn when a backup with locked notes is imported without a handler into a profile with no note lock key', async () => {
338+
await setUpUnlockedSession();
339+
const folder = await Folder.save({ title: 'folder' });
340+
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
341+
await lockNote(note.id);
342+
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
343+
344+
await setupDatabaseAndSynchronizer(2);
345+
await switchClient(2);
346+
NoteLockSession.destroyInstance();
347+
NoteLockKey.destroyInstance();
348+
Setting.setValue('featureFlag.noteLock', true);
349+
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
350+
351+
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']);
308352
});
309353

310354
it('should keep locked notes unchanged and warn when the provided key does not fit', async () => {

‎packages/lib/services/interop/InteropService_Exporter_Raw.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import BaseModel from '../../BaseModel';
44
import { basename } from '../../path-utils';
55
import shim from '../../shim';
66
import { BaseItemEntity, NoteEntity, ResourceEntity } from '../database/types';
7-
import isNoteLockEnabled from '../noteLock/isNoteLockEnabled';
7+
import EncryptionService from '../e2ee/EncryptionService';
88
import NoteLockNote from '../noteLock/NoteLockNote';
99
import NoteLockKey, { noteLockKeyFileName } from '../noteLock/NoteLockKey';
1010

@@ -22,8 +22,20 @@ export default class InteropService_Exporter_Raw extends InteropService_Exporter
2222
await shim.fsDriver().mkdir(this.resourceDir_);
2323
}
2424

25+
// Not behind the feature flag: importers that predate note lock ignore the key file. Only the profile's key can be exported,
26+
// so notes locked with another key do not bring it in.
27+
private async isLockedWithProfileKey_(note: NoteEntity) {
28+
const keyId = NoteLockKey.instance().load()?.id;
29+
if (!keyId || !NoteLockNote.isLocked(note)) return false;
30+
try {
31+
return (await EncryptionService.instance().decodeHeaderString(note.body, true)).masterKeyId === keyId;
32+
} catch {
33+
return false;
34+
}
35+
}
36+
2537
public async processItem(itemType: number, item: BaseItemEntity) {
26-
if (itemType === BaseModel.TYPE_NOTE && isNoteLockEnabled() && NoteLockNote.isLocked(item as NoteEntity)) this.hasLockedNotes_ = true;
38+
if (itemType === BaseModel.TYPE_NOTE && !this.hasLockedNotes_) this.hasLockedNotes_ = await this.isLockedWithProfileKey_(item as NoteEntity);
2739
const ItemClass = BaseItem.getClassByItemType(itemType);
2840
const serialized = await ItemClass.serialize(item);
2941
const filePath = `${this.destDir_}/${ItemClass.systemPath(item)}`;

‎packages/lib/services/interop/InteropService_Importer_Raw.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import uuid from '../../uuid';
1818
import isNoteLockEnabled from '../noteLock/isNoteLockEnabled';
1919
import NoteLockService from '../noteLock/NoteLockService';
2020
import NoteLockSession from '../noteLock/NoteLockSession';
21-
import { DecryptedNoteLockKey, noteLockKeyFileName } from '../noteLock/NoteLockKey';
21+
import NoteLockKey, { DecryptedNoteLockKey, noteLockKeyFileName } from '../noteLock/NoteLockKey';
2222

2323
export default class InteropService_Importer_Raw extends InteropService_Importer_Base {
2424
public async exec(result: ImportExportResult) {
@@ -47,8 +47,14 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
4747
let undecryptableNotes = 0;
4848
if (isNoteLockEnabled() && await shim.fsDriver().exists(`${this.sourcePath_}/${noteLockKeyFileName}`)) {
4949
const keyFile: MasterKeyEntity = JSON.parse(await shim.fsDriver().readFile(`${this.sourcePath_}/${noteLockKeyFileName}`));
50+
const profileKeyId = NoteLockKey.instance().load()?.id;
5051
if (keyFile?.id && this.options_.onNoteLockKey) {
5152
importNoteLockKey = await this.options_.onNoteLockKey(keyFile);
53+
} else if (keyFile?.id && !profileKeyId) {
54+
// Without a handler, as for the CLI, nothing can update the notes.
55+
result.warnings.push('The locked notes in this backup cannot be read, because no note lock key has been set up on this profile');
56+
} else if (keyFile?.id && keyFile.id !== profileKeyId) {
57+
result.warnings.push('The locked notes in this backup have not been migrated to the current note lock key and will be unreadable');
5258
}
5359
}
5460
// Captured once the prompts have unlocked the session, so locking it partway through does not fail the remaining notes.

0 commit comments

Comments
 (0)