diff --git a/packages/lib/services/EncryptionService.ts b/packages/lib/services/EncryptionService.ts index 11b0b7397..674e8fdf0 100644 --- a/packages/lib/services/EncryptionService.ts +++ b/packages/lib/services/EncryptionService.ts @@ -12,6 +12,12 @@ function hexPad(s: string, length: number) { return padLeft(s, length, '0'); } +export function isValidHeaderIdentifier(id: string, ignoreTooLongLength = false) { + if (!id) return false; + if (!ignoreTooLongLength && id.length !== 5) return false; + return /JED\d\d/.test(id); +} + export default class EncryptionService { public static instance_: EncryptionService = null; @@ -657,7 +663,7 @@ export default class EncryptionService { async decodeHeaderSource_(source: any) { const identifier = await source.read(5); - if (!this.isValidHeaderIdentifier(identifier)) throw new JoplinError(`Invalid encryption identifier. Data is not actually encrypted? ID was: ${identifier}`, 'invalidIdentifier'); + if (!isValidHeaderIdentifier(identifier)) throw new JoplinError(`Invalid encryption identifier. Data is not actually encrypted? ID was: ${identifier}`, 'invalidIdentifier'); const mdSizeHex = await source.read(6); const mdSize = parseInt(mdSizeHex, 16); if (isNaN(mdSize) || !mdSize) throw new Error(`Invalid header metadata size: ${mdSizeHex}`); @@ -697,12 +703,6 @@ export default class EncryptionService { return output; } - isValidHeaderIdentifier(id: string, ignoreTooLongLength = false) { - if (!id) return false; - if (!ignoreTooLongLength && id.length !== 5) return false; - return /JED\d\d/.test(id); - } - isValidEncryptionMethod(method: number) { return [EncryptionService.METHOD_SJCL, EncryptionService.METHOD_SJCL_1A, EncryptionService.METHOD_SJCL_2, EncryptionService.METHOD_SJCL_3, EncryptionService.METHOD_SJCL_4].indexOf(method) >= 0; } @@ -711,13 +711,13 @@ export default class EncryptionService { if (!item) throw new Error('No item'); const ItemClass = BaseItem.itemClass(item); if (!ItemClass.encryptionSupported()) return false; - return item.encryption_applied && this.isValidHeaderIdentifier(item.encryption_cipher_text, true); + return item.encryption_applied && isValidHeaderIdentifier(item.encryption_cipher_text, true); } async fileIsEncrypted(path: string) { const handle = await this.fsDriver().open(path, 'r'); const headerIdentifier = await this.fsDriver().readFileChunk(handle, 5, 'ascii'); await this.fsDriver().close(handle); - return this.isValidHeaderIdentifier(headerIdentifier); + return isValidHeaderIdentifier(headerIdentifier); } } diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts index 0f93ff945..ea76ba168 100644 --- a/packages/server/src/models/UserModel.ts +++ b/packages/server/src/models/UserModel.ts @@ -5,6 +5,7 @@ import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNo import { ModelType } from '@joplin/lib/BaseModel'; import { _ } from '@joplin/lib/locale'; import { formatBytes, MB } from '../utils/bytes'; +import { itemIsEncrypted } from '../utils/joplinUtils'; export enum AccountType { Default = 0, @@ -147,7 +148,8 @@ export default class UserModel extends BaseModel { // If the item is encrypted, we apply a multipler because encrypted // items can be much larger (seems to be up to twice the size but for // safety let's go with 2.2). - const maxSize = user.max_item_size * (item.jop_encryption_applied ? 2.2 : 1); + + const maxSize = user.max_item_size * (itemIsEncrypted(item) ? 2.2 : 1); if (maxSize && buffer.byteLength > maxSize) { const itemTitle = joplinItem ? joplinItem.title || '' : ''; const isNote = joplinItem && joplinItem.type_ === ModelType.Note; diff --git a/packages/server/src/utils/joplinUtils.test.ts b/packages/server/src/utils/joplinUtils.test.ts new file mode 100644 index 000000000..5e372711d --- /dev/null +++ b/packages/server/src/utils/joplinUtils.test.ts @@ -0,0 +1,24 @@ +import { Item } from '../db'; +import { itemIsEncrypted } from './joplinUtils'; +import { expectThrow } from './testing/testUtils'; + +describe('joplinUtils', function() { + + it('should check if an item is encrypted', async function() { + type TestCase = [boolean, Item]; + + const testCases: TestCase[] = [ + [true, { jop_encryption_applied: 1 }], + [false, { jop_encryption_applied: 0 }], + [true, { content: Buffer.from('JED01blablablabla', 'utf8') }], + [false, { content: Buffer.from('plain text', 'utf8') }], + ]; + + for (const [expected, input] of testCases) { + expect(itemIsEncrypted(input)).toBe(expected); + } + + await expectThrow(async () => itemIsEncrypted({ name: 'missing props' })); + }); + +}); diff --git a/packages/server/src/utils/joplinUtils.ts b/packages/server/src/utils/joplinUtils.ts index 755795331..957b51fa0 100644 --- a/packages/server/src/utils/joplinUtils.ts +++ b/packages/server/src/utils/joplinUtils.ts @@ -18,6 +18,7 @@ import { formatDateTime } from './time'; import { ErrorNotFound } from './errors'; import { MarkupToHtml } from '@joplin/renderer'; import { OptionsResourceModel } from '@joplin/renderer/MarkupToHtml'; +import { isValidHeaderIdentifier } from '@joplin/lib/services/EncryptionService'; const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js'); import { themeStyle } from '@joplin/lib/theme'; import Setting from '@joplin/lib/models/Setting'; @@ -221,6 +222,13 @@ async function renderNote(share: Share, note: NoteEntity, resourceInfos: Resourc }; } +export function itemIsEncrypted(item: Item): boolean { + if ('jop_encryption_applied' in item) return !!item.jop_encryption_applied; + if (!('content' in item)) throw new Error('Cannot check encryption - item is missing both "content" and "jop_encryption_applied" property'); + const header = item.content.toString('utf8', 0, 5); + return isValidHeaderIdentifier(header); +} + export async function renderItem(userId: Uuid, item: Item, share: Share, query: Record): Promise { const rootNote: NoteEntity = models_.item().itemToJoplinItem(item); // await this.unserializeItem(content); const linkedItemInfos: LinkedItemInfos = await noteLinkedItemInfos(userId, models_.item(), rootNote);