diff --git a/lib/package-lock.json b/lib/package-lock.json index aaba0672..319a9396 100644 --- a/lib/package-lock.json +++ b/lib/package-lock.json @@ -19,6 +19,7 @@ "dpop": "^1.2.0", "events": "^3.3.0", "jose": "^4.14.4", + "p-limit": "^5.0.0", "streamsaver": "^2.0.6", "uuid": "~9.0.0" }, @@ -48,6 +49,7 @@ "colors": "^1.4.0", "eslint": "^8.46.0", "eslint-config-prettier": "^8.9.0", + "fetch-blob": "^4.0.0", "glob": "^10.3.3", "jsdom": "^22.1.0", "karma": "^6.4.2", @@ -4972,6 +4974,28 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-blob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-4.0.0.tgz", + "integrity": "sha512-nPmnhRmpNMjYWnp9EBMGs6z5lq9RXed5W1vuZcECrsDVQInM8AMQSooVb3X183Aole60adzjWbH9qlRFWzDDTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0" + }, + "engines": { + "node": ">=16.7" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "dev": true, @@ -7860,6 +7884,25 @@ "path-to-regexp": "^1.7.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", @@ -8232,15 +8275,14 @@ } }, "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", "dependencies": { - "yocto-queue": "^0.1.0" + "yocto-queue": "^1.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8261,6 +8303,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", @@ -11154,12 +11223,11 @@ } }, "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", "engines": { - "node": ">=10" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -14771,6 +14839,15 @@ "pend": "~1.2.0" } }, + "fetch-blob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-4.0.0.tgz", + "integrity": "sha512-nPmnhRmpNMjYWnp9EBMGs6z5lq9RXed5W1vuZcECrsDVQInM8AMQSooVb3X183Aole60adzjWbH9qlRFWzDDTA==", + "dev": true, + "requires": { + "node-domexception": "^1.0.0" + } + }, "file-entry-cache": { "version": "6.0.1", "dev": true, @@ -16848,6 +16925,12 @@ "path-to-regexp": "^1.7.0" } }, + "node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "dev": true + }, "node-fetch": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", @@ -17135,12 +17218,11 @@ } }, "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", "requires": { - "yocto-queue": "^0.1.0" + "yocto-queue": "^1.0.0" } }, "p-locate": { @@ -17150,6 +17232,23 @@ "dev": true, "requires": { "p-limit": "^3.0.2" + }, + "dependencies": { + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + } } }, "p-map": { @@ -19217,10 +19316,9 @@ "dev": true }, "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==" } } } diff --git a/lib/package.json b/lib/package.json index f612de8c..27070359 100644 --- a/lib/package.json +++ b/lib/package.json @@ -53,8 +53,8 @@ "lint": "eslint ./src/**/*.ts ./tdf3/**/*.ts ./tests/**/*.ts", "prepack": "npm run build", "test": "npm run build && npm run test:mocha && npm run test:wtr && npm run test:browser && npm run coverage:merge", - "test:browser": "node dist/web/tests/server.js & trap \"node dist/web/tests/stopServer.js\" EXIT; npx webpack --config webpack.test.config.cjs && npx karma start karma.conf.cjs", - "test:mocha": "node dist/web/tests/server.js & trap \"node dist/web/tests/stopServer.js\" EXIT; c8 --exclude=\"dist/web/tests/\" --exclude=\"dist/web/tdf3/src/utils/aws-lib-storage/\" --exclude=\"dist/web/tests/**/*\" --report-dir=./coverage/mocha mocha 'dist/web/tests/mocha/**/*.spec.js' --file dist/web/tests/mocha/setup.js && npx c8 report --reporter=json --report-dir=./coverage/mocha", + "test:browser": "node --require ./preServer.js dist/web/tests/server.js & trap \"node dist/web/tests/stopServer.js\" EXIT; npx webpack --config webpack.test.config.cjs && npx karma start karma.conf.cjs", + "test:mocha": "node --require ./preServer.js dist/web/tests/server.js & trap \"node dist/web/tests/stopServer.js\" EXIT; c8 --exclude=\"dist/web/tests/\" --exclude=\"dist/web/tdf3/src/utils/aws-lib-storage/\" --exclude=\"dist/web/tests/**/*\" --report-dir=./coverage/mocha mocha 'dist/web/tests/mocha/**/*.spec.js' --file dist/web/tests/mocha/setup.js && npx c8 report --reporter=json --report-dir=./coverage/mocha", "test:wtr": "web-test-runner", "watch": "(trap 'kill 0' SIGINT; npm run build && (npm run build:watch & npm run test -- --watch))" }, @@ -69,6 +69,7 @@ "dpop": "^1.2.0", "events": "^3.3.0", "jose": "^4.14.4", + "p-limit": "^5.0.0", "streamsaver": "^2.0.6", "uuid": "~9.0.0" }, @@ -98,6 +99,7 @@ "colors": "^1.4.0", "eslint": "^8.46.0", "eslint-config-prettier": "^8.9.0", + "fetch-blob": "^4.0.0", "glob": "^10.3.3", "jsdom": "^22.1.0", "karma": "^6.4.2", diff --git a/lib/preServer.js b/lib/preServer.js new file mode 100644 index 00000000..232378ac --- /dev/null +++ b/lib/preServer.js @@ -0,0 +1 @@ +import('./dist/web/tests/mocha/setup.js').catch(e => console.error(e)); diff --git a/lib/tdf3/src/crypto/decrypt-worker.ts b/lib/tdf3/src/crypto/decrypt-worker.ts new file mode 100644 index 00000000..d27fd53f --- /dev/null +++ b/lib/tdf3/src/crypto/decrypt-worker.ts @@ -0,0 +1,75 @@ +import { TdfDecryptError } from '../../../src/errors.js'; + +const maxWorkers = navigator?.hardwareConcurrency || 4; // save fallback number + +interface DecryptData { + key: CryptoKey; + encryptedPayload: ArrayBuffer; + algo: AesCbcParams | AesGcmParams; +} + +const workerScript = ` + self.onmessage = async (event) => { + const { key, encryptedPayload, algo } = event.data; + + try { + const decryptedData = await crypto.subtle.decrypt(algo, key, encryptedPayload); + self.postMessage({ success: true, data: decryptedData }); + } catch (error) { + self.postMessage({ success: false, error: error.message }); + } + }; +`; + +const workerBlob = new Blob([workerScript], { type: 'application/javascript' }); +const workerUrl = URL.createObjectURL(workerBlob); +const workersArray: Worker[] = Array.from({ length: maxWorkers }, () => new Worker(workerUrl)); + +interface WorkersQueue { + freeWorkers: Worker[]; + resolvers: ((worker: Worker) => void)[]; + push: (worker: Worker) => void; + pop: () => Promise; +} + +export const workersQueue: WorkersQueue = { + freeWorkers: [...workersArray], + resolvers: [], + + push(worker: Worker) { + const resolve = this.resolvers.shift(); + if (typeof resolve === 'function') { + resolve(worker); + } else { + this.freeWorkers.push(worker); + } + }, + + pop(): Promise { + return new Promise((resolve) => { + const worker = this.freeWorkers.shift(); + if (worker instanceof Worker) { + resolve(worker); + } else { + this.resolvers.push(resolve); + } + }); + }, +}; + +export async function decrypt(data: DecryptData): Promise { + const worker: Worker = await workersQueue.pop(); + return await new Promise((resolve, reject) => { + worker.onmessage = (event) => { + const { success, data, error } = event.data; + workersQueue.push(worker); + if (success) { + resolve(data as ArrayBuffer); + } else { + reject(new TdfDecryptError(error)); + } + }; + + worker.postMessage(data); + }); +} diff --git a/lib/tdf3/src/crypto/index.ts b/lib/tdf3/src/crypto/index.ts index a975c856..8f70df92 100644 --- a/lib/tdf3/src/crypto/index.ts +++ b/lib/tdf3/src/crypto/index.ts @@ -5,6 +5,7 @@ */ import { Algorithms } from '../ciphers/index.js'; +import { decrypt as workerDecrypt } from './decrypt-worker.js'; import { Binary } from '../binary.js'; import { CryptoService, @@ -274,17 +275,23 @@ async function _doDecrypt( const importedKey = await _importKey(key, algoDomString); algoDomString.iv = iv.asArrayBuffer(); - const decrypted = await crypto.subtle - .decrypt(algoDomString, importedKey, payloadBuffer) - // Catching this error so we can specifically check for OperationError - .catch((err) => { - if (err.name === 'OperationError') { - throw new TdfDecryptError(err); - } - - throw err; - }); - return { payload: Binary.fromArrayBuffer(decrypted) }; + try { + const decrypted = navigator?.hardwareConcurrency + ? await workerDecrypt({ + key: importedKey, + encryptedPayload: payloadBuffer, + algo: algoDomString, + }) + : await crypto.subtle.decrypt(algoDomString, importedKey, payloadBuffer); + + return { payload: Binary.fromArrayBuffer(decrypted) }; + } catch (err) { + if (err.name === 'OperationError') { + throw new TdfDecryptError(err); + } + + throw err; + } } function _importKey(key: Binary, algorithm: AesCbcParams | AesGcmParams) { diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 027ba2c3..71faf87a 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -5,6 +5,7 @@ import { DecoratedReadableStream } from './client/DecoratedReadableStream.js'; import { EntityObject } from '../../src/tdf/EntityObject.js'; import { validateSecureUrl } from '../../src/utils.js'; import { DecryptParams } from './client/builders.js'; +import pLimit from 'p-limit'; import { AttributeSet, @@ -922,7 +923,7 @@ async function updateChunkQueue( segmentIntegrityAlgorithm: IntegrityAlgorithm, cryptoService: CryptoService ) { - const chunksInOneDownload = 500; + const chunksInOneDownload = 300; let requests = []; const maxLength = 3; @@ -981,6 +982,10 @@ export async function sliceAndDecrypt({ cryptoService: CryptoService; segmentIntegrityAlgorithm: IntegrityAlgorithm; }) { + const limit = pLimit(navigator.hardwareConcurrency || 4); // save fallback number + + const promises = []; + for (const index in slice) { const { encryptedOffset, encryptedSegmentSize } = slice[index]; @@ -990,18 +995,26 @@ export async function sliceAndDecrypt({ buffer.slice(offset, offset + (encryptedSegmentSize as number)) ); - slice[index].decryptedChunk = await decryptChunk( - encryptedChunk, - reconstructedKeyBinary, - slice[index]['hash'], - cipher, - segmentIntegrityAlgorithm, - cryptoService + promises.push( + limit(() => + decryptChunk( + encryptedChunk, + reconstructedKeyBinary, + slice[index]['hash'], + cipher, + segmentIntegrityAlgorithm, + cryptoService + ).then((decryptedChunk) => { + if (!decryptedChunk) return; + slice[index].decryptedChunk = decryptedChunk; + if (slice[index]._resolve) { + (slice[index]._resolve as (value: unknown) => void)(null); + } + }) + ) ); - if (slice[index]._resolve) { - (slice[index]._resolve as (value: unknown) => void)(null); - } } + await Promise.all(promises); } export async function readStream(cfg: DecryptConfiguration) { @@ -1061,7 +1074,8 @@ export async function readStream(cfg: DecryptConfiguration) { const cipher = new AesGcmCipher(cfg.cryptoService); - await updateChunkQueue( + // no await here + updateChunkQueue( Array.from(chunkMap.values()), centralDirectory, zipReader, diff --git a/lib/tdf3/types.d.ts b/lib/tdf3/types.d.ts index 78d139fe..42d50701 100644 --- a/lib/tdf3/types.d.ts +++ b/lib/tdf3/types.d.ts @@ -2,6 +2,7 @@ declare global { interface Window { TDF: unknown; InstallTrigger: unknown; + activeWorkers: Set; } interface Crypto { diff --git a/lib/tests/mocha/setup.ts b/lib/tests/mocha/setup.ts index 5e9d0041..528b5ad0 100644 --- a/lib/tests/mocha/setup.ts +++ b/lib/tests/mocha/setup.ts @@ -12,6 +12,13 @@ if (typeof globalThis.crypto === 'undefined') { globalThis.crypto = webcrypto; } +if (typeof globalThis.Worker === 'undefined') { + // @ts-expect-error: custom Worker class + globalThis.Worker = class Worker { + constructor() {} + }; +} + const jsdom = new JSDOM(''); const { window } = jsdom; diff --git a/lib/tests/mocha/unit/decrypt-worker.spec.ts b/lib/tests/mocha/unit/decrypt-worker.spec.ts new file mode 100644 index 00000000..60503bcb --- /dev/null +++ b/lib/tests/mocha/unit/decrypt-worker.spec.ts @@ -0,0 +1,25 @@ +import { decrypt } from '../../../tdf3/src/crypto/decrypt-worker'; +import sinon from 'sinon'; + +const WorkerImplementation = globalThis.Worker; + +describe('', () => { + beforeAll(() => { + globalThis.Worker = class Worker { + private spy: sinon.SinonSpy; + postMessage(data) { + this.spy = sinon.spy(); + this.spy(data); + return this.onmessage(data) + } + onmessage(data) {} + }; + }) + it('', async () => { + + }); + afterAll(() => { + globalThis.Worker = WorkerImplementation; + }) +}); + diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 7fec5f85..58874695 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -1,5 +1,3 @@ -import './mocha/setup.js'; - import * as jose from 'jose'; import { IncomingMessage, RequestListener, createServer } from 'node:http';