fix(standalone): encrypt subtree not working

This commit is contained in:
Elian Doran
2026-04-12 19:52:34 +03:00
parent de037b3ced
commit 395c71fa0d
5 changed files with 102 additions and 59 deletions

View File

@@ -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",

View File

@@ -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<Uint8Array> {
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;
}
}
}

View File

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

View File

@@ -29,6 +29,15 @@ function encrypt(plainText: string | Uint8Array) {
return dataEncryptionService.encrypt(dataKey, plainText);
}
async function encryptAsync(plainText: string | Uint8Array): Promise<string | null> {
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<Uint8Array | null> {
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<string | null> {
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
};

16
pnpm-lock.yaml generated
View File

@@ -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