diff --git a/apps/client-standalone/package.json b/apps/client-standalone/package.json index 726cf138ce..b6da1301f5 100644 --- a/apps/client-standalone/package.json +++ b/apps/client-standalone/package.json @@ -45,6 +45,7 @@ "globals": "17.4.0", "i18next": "26.0.3", "i18next-http-backend": "3.0.4", + "aes-js": "3.1.2", "jquery": "4.0.0", "jquery.fancytree": "2.38.5", "js-md5": "0.8.3", @@ -72,6 +73,7 @@ "vanilla-js-wheel-zoom": "9.0.4" }, "devDependencies": { + "@types/aes-js": "3.1.4", "@ckeditor/ckeditor5-inspector": "5.0.0", "@preact/preset-vite": "2.10.2", "@types/bootstrap": "5.2.10", diff --git a/apps/client-standalone/src/lightweight/crypto_provider.ts b/apps/client-standalone/src/lightweight/crypto_provider.ts index 2c5f96339e..6aa46ba525 100644 --- a/apps/client-standalone/src/lightweight/crypto_provider.ts +++ b/apps/client-standalone/src/lightweight/crypto_provider.ts @@ -5,11 +5,13 @@ import { sha256 } from "js-sha256"; import { sha512 } from "js-sha512"; import { md5 } from "js-md5"; import { scrypt } from "scrypt-js"; +import aesjs from "aes-js"; const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; /** - * Crypto provider for browser environments using the Web Crypto API. + * Crypto provider for browser environments using pure JavaScript crypto libraries. + * Uses aes-js for synchronous AES encryption (matching Node.js behavior). */ export default class BrowserCryptoProvider implements CryptoProvider { @@ -34,13 +36,11 @@ export default class BrowserCryptoProvider implements CryptoProvider { } createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher { - // Web Crypto API doesn't support streaming cipher like Node.js - // We need to implement a wrapper that collects data and encrypts on final() - return new WebCryptoCipher(algorithm, key, iv, "encrypt"); + return new AesJsCipher(algorithm, key, iv, "encrypt"); } createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher { - return new WebCryptoCipher(algorithm, key, iv, "decrypt"); + return new AesJsCipher(algorithm, key, iv, "decrypt"); } randomBytes(size: number): Uint8Array { @@ -102,25 +102,22 @@ export default class BrowserCryptoProvider implements CryptoProvider { } /** - * A cipher implementation that wraps Web Crypto API. - * Note: This buffers all data until final() is called, which differs from - * Node.js's streaming cipher behavior. + * A synchronous cipher implementation using aes-js. + * Matches Node.js crypto behavior with update() and final() methods. */ -class WebCryptoCipher implements Cipher { +class AesJsCipher implements Cipher { private chunks: Uint8Array[] = []; - private algorithm: string; private key: Uint8Array; private iv: Uint8Array; private mode: "encrypt" | "decrypt"; private finalized = false; constructor( - algorithm: "aes-128-cbc", + _algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array, mode: "encrypt" | "decrypt" ) { - this.algorithm = algorithm; this.key = key; this.iv = iv; this.mode = mode; @@ -130,9 +127,9 @@ class WebCryptoCipher implements Cipher { if (this.finalized) { throw new Error("Cipher has already been finalized"); } - // Buffer the data - Web Crypto doesn't support streaming + // Buffer the data - we process everything in final() to match streaming behavior this.chunks.push(data); - // Return empty array since we process everything in final() + // Return empty array since aes-js CBC doesn't support true streaming return new Uint8Array(0); } @@ -142,24 +139,6 @@ class WebCryptoCipher implements Cipher { } this.finalized = true; - // Web Crypto API is async, but we need sync behavior - // This is a fundamental limitation that requires architectural changes - // For now, throw an error directing users to use async methods - throw new Error( - "Synchronous cipher finalization not available in browser. " + - "The Web Crypto API is async-only. Use finalizeAsync() instead." - ); - } - - /** - * Async version that actually performs the encryption/decryption. - */ - async finalizeAsync(): Promise { - if (this.finalized) { - throw new Error("Cipher has already been finalized"); - } - this.finalized = true; - // Concatenate all chunks const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0); const data = new Uint8Array(totalLength); @@ -169,24 +148,33 @@ class WebCryptoCipher implements Cipher { offset += chunk.length; } - // Copy key and iv to ensure they're plain ArrayBuffer-backed - const keyBuffer = new Uint8Array(this.key); - const ivBuffer = new Uint8Array(this.iv); + if (this.mode === "encrypt") { + // PKCS7 padding for encryption + const blockSize = 16; + const paddingLength = blockSize - (data.length % blockSize); + const paddedData = new Uint8Array(data.length + paddingLength); + paddedData.set(data); + paddedData.fill(paddingLength, data.length); - // Import the key - const cryptoKey = await crypto.subtle.importKey( - "raw", - keyBuffer, - { name: "AES-CBC" }, - false, - [this.mode] - ); + const aesCbc = new aesjs.ModeOfOperation.cbc( + Array.from(this.key), + Array.from(this.iv) + ); + return new Uint8Array(aesCbc.encrypt(paddedData)); + } else { + // Decryption + const aesCbc = new aesjs.ModeOfOperation.cbc( + Array.from(this.key), + Array.from(this.iv) + ); + const decrypted = new Uint8Array(aesCbc.decrypt(data)); - // Perform encryption/decryption - const result = this.mode === "encrypt" - ? await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data) - : await crypto.subtle.decrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data); - - return new Uint8Array(result); + // Remove PKCS7 padding + const paddingLength = decrypted[decrypted.length - 1]; + if (paddingLength > 0 && paddingLength <= 16) { + return decrypted.slice(0, decrypted.length - paddingLength); + } + return decrypted; + } } } diff --git a/packages/trilium-core/src/services/encryption/data_encryption.ts b/packages/trilium-core/src/services/encryption/data_encryption.ts index ff0274731d..2280c62446 100644 --- a/packages/trilium-core/src/services/encryption/data_encryption.ts +++ b/packages/trilium-core/src/services/encryption/data_encryption.ts @@ -125,12 +125,16 @@ async function encryptAsync(key: Uint8Array, plainText: Uint8Array | string): Pr const digest = shaArray(plainTextUint8Array).slice(0, 4); const digestWithPayload = concat2(digest, plainTextUint8Array); - cipher.update(digestWithPayload); - // Use async finalization if available (browser), otherwise sync (Node.js) - const encryptedData = cipher.finalizeAsync - ? await cipher.finalizeAsync() - : concat2(cipher.update(digestWithPayload), cipher.final()); + let encryptedData: Uint8Array; + if (cipher.finalizeAsync) { + // Browser: update() buffers data, finalizeAsync() encrypts and returns all + cipher.update(digestWithPayload); + encryptedData = await cipher.finalizeAsync(); + } else { + // Node.js: update() and final() both return encrypted chunks + encryptedData = concat2(cipher.update(digestWithPayload), cipher.final()); + } const encryptedDataWithIv = concat2(iv, encryptedData); @@ -162,12 +166,16 @@ async function decryptAsync(key: Uint8Array, cipherText: string | Uint8Array): P const decipher = getCrypto().createDecipheriv("aes-128-cbc", pad(key), pad(iv)); - decipher.update(cipherTextUint8Array); - // Use async finalization if available (browser), otherwise sync (Node.js) - const decryptedBytes = decipher.finalizeAsync - ? await decipher.finalizeAsync() - : concat2(decipher.update(cipherTextUint8Array), decipher.final()); + let decryptedBytes: Uint8Array; + if (decipher.finalizeAsync) { + // Browser: update() buffers data, finalizeAsync() decrypts and returns all + decipher.update(cipherTextUint8Array); + decryptedBytes = await decipher.finalizeAsync(); + } else { + // Node.js: update() and final() both return decrypted chunks + decryptedBytes = concat2(decipher.update(cipherTextUint8Array), decipher.final()); + } const digest = decryptedBytes.slice(0, 4); const payload = decryptedBytes.slice(4); diff --git a/packages/trilium-core/src/services/protected_session.ts b/packages/trilium-core/src/services/protected_session.ts index f336fbccc4..da59875241 100644 --- a/packages/trilium-core/src/services/protected_session.ts +++ b/packages/trilium-core/src/services/protected_session.ts @@ -29,6 +29,15 @@ function encrypt(plainText: string | Uint8Array) { return dataEncryptionService.encrypt(dataKey, plainText); } +async function encryptAsync(plainText: string | Uint8Array): Promise { + const dataKey = getDataKey(); + if (plainText === null || dataKey === null) { + return null; + } + + return dataEncryptionService.encryptAsync(dataKey, plainText); +} + function decrypt(cipherText: string | Uint8Array): Uint8Array | null { const dataKey = getDataKey(); if (cipherText === null || dataKey === null) { @@ -38,6 +47,15 @@ function decrypt(cipherText: string | Uint8Array): Uint8Array | null { return dataEncryptionService.decrypt(dataKey, cipherText) || null; } +async function decryptAsync(cipherText: string | Uint8Array): Promise { + const dataKey = getDataKey(); + if (cipherText === null || dataKey === null) { + return null; + } + + return (await dataEncryptionService.decryptAsync(dataKey, cipherText)) || null; +} + function decryptString(cipherText: string): string | null { const dataKey = getDataKey(); if (dataKey === null) { @@ -46,6 +64,14 @@ function decryptString(cipherText: string): string | null { return dataEncryptionService.decryptString(dataKey, cipherText); } +async function decryptStringAsync(cipherText: string): Promise { + const dataKey = getDataKey(); + if (dataKey === null) { + return null; + } + return dataEncryptionService.decryptStringAsync(dataKey, cipherText); +} + let lastProtectedSessionOperationDate: number | null = null; function touchProtectedSession() { @@ -63,8 +89,11 @@ export default { resetDataKey, isProtectedSessionAvailable, encrypt, + encryptAsync, decrypt, + decryptAsync, decryptString, + decryptStringAsync, touchProtectedSession, getLastProtectedSessionOperationDate }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc2db6486b..1c9c2a807c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -491,6 +491,9 @@ importers: '@zumer/snapdom': specifier: 2.7.0 version: 2.7.0 + aes-js: + specifier: 3.1.2 + version: 3.1.2 autocomplete.js: specifier: 0.38.1 version: 0.38.1 @@ -609,6 +612,9 @@ importers: '@preact/preset-vite': specifier: 2.10.2 version: 2.10.2(@babel/core@7.29.0)(preact@10.29.1)(vite@8.0.7(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(less@4.1.3)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) + '@types/aes-js': + specifier: 3.1.4 + version: 3.1.4 '@types/bootstrap': specifier: 5.2.10 version: 5.2.10 @@ -5469,6 +5475,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aes-js@3.1.4': + resolution: {integrity: sha512-v3D66IptpUqh+pHKVNRxY8yvp2ESSZXe0rTzsGdzUhEwag7ljVfgCllkWv2YgiYXDhWFBrEywll4A5JToyTNFA==} + '@types/appdmg@0.5.5': resolution: {integrity: sha512-G+n6DgZTZFOteITE30LnWj+HRVIGr7wMlAiLWOO02uJFWVEitaPU9JVXm9wJokkgshBawb2O1OykdcsmkkZfgg==} @@ -6866,6 +6875,9 @@ packages: resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} engines: {node: '>=12.0'} + aes-js@3.1.2: + resolution: {integrity: sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -19078,6 +19090,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aes-js@3.1.4': {} + '@types/appdmg@0.5.5': dependencies: '@types/node': 24.12.2 @@ -21588,6 +21602,8 @@ snapshots: adm-zip@0.5.16: {} + aes-js@3.1.2: {} + agent-base@6.0.2: dependencies: debug: 4.4.3