1 import type { PrivateKeyReference, SessionKey } from '@proton/crypto';
2 import { CryptoProxy, serverTime, updateServerTime } from '@proton/crypto';
3 import type { SafeErrorObject } from '@proton/utils/getSafeErrorObject';
4 import { getSafeErrorObject } from '@proton/utils/getSafeErrorObject';
6 import { convertSafeError } from '../../utils/errorHandling/EnrichedError';
7 import { RefreshError, getRefreshError } from '../../utils/errorHandling/RefreshError';
8 import { HEARTBEAT_INTERVAL, HEARTBEAT_WAIT_TIME, WORKER_INIT_WAIT_TIME } from './constants';
15 ThumbnailEncryptedBlock,
16 ThumbnailRequestBlock,
19 import type { Media, ThumbnailInfo } from './media';
21 type GenerateKeysMessage = {
22 command: 'generate_keys';
23 addressPrivateKey: Uint8Array | undefined;
24 parentPrivateKey: Uint8Array;
33 thumbnails?: ThumbnailInfo[];
35 addressPrivateKey: Uint8Array | undefined;
37 privateKey: Uint8Array;
38 sessionKey: SessionKey;
39 parentHashKey: Uint8Array;
40 verificationData: VerificationData;
43 type CreatedBlocksMessage = {
44 command: 'created_blocks';
46 thumbnailLinks?: Link[];
53 type ResumeMessage = {
62 * WorkerControllerEvent contains all possible events which can come from
63 * the main thread to the upload web worker.
65 type WorkerControllerEvent = {
66 data: GenerateKeysMessage | StartMessage | CreatedBlocksMessage | PauseMessage | ResumeMessage | CloseMessage;
70 * WorkerHandlers defines what handlers are available to be used in the upload
71 * web worker to messages from the main thread defined in WorkerControllerEvent.
73 interface WorkerHandlers {
74 generateKeys: (addressPrivateKey: PrivateKeyReference | undefined, parentPrivateKey: PrivateKeyReference) => void;
85 thumbnails?: ThumbnailInfo[];
88 addressPrivateKey: PrivateKeyReference | undefined,
90 privateKey: PrivateKeyReference,
91 sessionKey: SessionKey,
92 parentHashKey: Uint8Array,
93 verificationData: VerificationData
95 createdBlocks: (fileLinks: Link[], thumbnailLinks?: Link[]) => void;
100 type KeysGeneratedMessage = {
101 command: 'keys_generated';
103 nodePassphrase: string;
104 nodePassphraseSignature: string;
105 contentKeyPacket: string;
106 contentKeyPacketSignature: string;
107 privateKey: Uint8Array;
108 sessionKey: SessionKey;
111 type CreateBlockMessage = {
112 command: 'create_blocks';
113 fileBlocks: FileRequestBlock[];
114 thumbnailBlocks?: ThumbnailRequestBlock[];
117 type ProgressMessage = {
125 signatureAddress: string;
130 type NetworkErrorMessage = {
131 command: 'network_error';
132 error: SafeErrorObject;
135 type ErrorMessage = {
137 error: SafeErrorObject;
140 type NotifyVerificationError = {
141 command: 'notify_verification_error';
142 retryHelped: boolean;
145 type HeartbeatMessage = {
146 command: 'heartbeat';
149 type WorkerAliveMessage = {
159 * WorkerEvent contains all possible events which can come from the upload
160 * web worker to the main thread.
164 | KeysGeneratedMessage
168 | NetworkErrorMessage
170 | NotifyVerificationError
177 * WorkerControllerHandlers defines what handlers are available to be used
178 * in the main thread to messages from the upload web worked defined in
181 interface WorkerControllerHandlers {
182 keysGenerated: (keys: FileKeys) => void;
183 createBlocks: (fileBlocks: FileRequestBlock[], thumbnailBlocks?: ThumbnailRequestBlock[]) => void;
184 onProgress: (increment: number) => void;
185 finalize: (signature: string, signatureAddress: string, xattr: string, photo?: PhotoUpload) => void;
186 onNetworkError: (error: Error) => void;
187 onError: (error: Error) => void;
188 onHeartbeatTimeout: () => void;
189 onCancel: () => void;
190 notifySentry: (error: Error) => void;
191 notifyVerificationError: (retryHelped: boolean) => void;
195 * UploadWorker provides communication between the main thread and upload web
196 * worker. The class ensures type safety as much as possible.
197 * UploadWorker is meant to be used on the side of the web worker.
199 export class UploadWorker {
202 heartbeatInterval?: NodeJS.Timeout;
204 constructor(worker: Worker, { generateKeys, start, createdBlocks, pause, resume }: WorkerHandlers) {
205 // Before the worker termination, we want to release securely crypto
206 // proxy. That might need a bit of time, and we allow up to few seconds
207 // before we terminate the worker. During the releasing time, crypto
208 // might be failing, so any error should be ignored.
211 this.worker = worker;
213 // Notify the main thread we are alive.
214 // We use this message to check for failures to load the worker.
215 this.postWorkerAlive();
217 // Set up the heartbeat. This notifies the main thread that the worker is still alive.
218 this.heartbeatInterval = setInterval(() => this.postHeartbeat(), HEARTBEAT_INTERVAL);
220 worker.addEventListener('message', ({ data }: WorkerControllerEvent) => {
221 switch (data.command) {
222 case 'generate_keys':
225 // Dynamic import is needed since we want pmcrypto (incl. openpgpjs) to be loaded
226 // inside the worker, not in the main thread.
228 module = await import(
229 /* webpackChunkName: "crypto-worker-api" */ '@proton/crypto/lib/worker/api'
233 this.postError(new RefreshError());
237 const { Api: CryptoApi } = module;
240 CryptoProxy.setEndpoint(new CryptoApi(), (endpoint) => endpoint.clearKeyStore());
242 // align serverTime in worker with the main thread (received from API)
243 updateServerTime(data.serverTime);
244 const addressPrivateKey = data.addressPrivateKey
245 ? await CryptoProxy.importPrivateKey({
246 binaryKey: data.addressPrivateKey,
251 const parentPrivateKey = await CryptoProxy.importPrivateKey({
252 binaryKey: data.parentPrivateKey,
255 generateKeys(addressPrivateKey, parentPrivateKey);
256 })(data).catch((err) => {
262 const addressPrivateKey = data.addressPrivateKey
263 ? await CryptoProxy.importPrivateKey({
264 binaryKey: data.addressPrivateKey,
268 const privateKey = await CryptoProxy.importPrivateKey({
269 binaryKey: data.privateKey,
275 mimeType: data.mimeType,
276 isForPhotos: data.isForPhotos,
277 thumbnails: data.thumbnails,
285 data.verificationData
287 })(data).catch((err) => {
291 case 'created_blocks':
292 createdBlocks(data.fileLinks, data.thumbnailLinks);
302 this.clearHeartbeatInterval();
303 void CryptoProxy.releaseEndpoint().then(() => self.close());
306 // Type linters should prevent this error.
307 throw new Error('Unexpected message');
310 worker.addEventListener('error', (event: ErrorEvent) => {
315 this.postError(event.error || new Error(event.message));
318 worker.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
319 event.preventDefault();
324 let error = event.reason;
326 if (typeof error === 'string') {
327 error = new Error(error);
330 this.postError(error);
334 clearHeartbeatInterval() {
335 if (this.heartbeatInterval) {
336 clearInterval(this.heartbeatInterval);
340 async postKeysGenerated(keys: FileKeys) {
341 this.worker.postMessage({
342 command: 'keys_generated',
344 privateKey: await CryptoProxy.exportPrivateKey({
345 privateKey: keys.privateKey,
349 } satisfies KeysGeneratedMessage);
352 postCreateBlocks(fileBlocks: EncryptedBlock[], encryptedThumbnailBlocks?: ThumbnailEncryptedBlock[]) {
353 this.worker.postMessage({
354 command: 'create_blocks',
355 fileBlocks: fileBlocks.map<FileRequestBlock>((block) => ({
357 signature: block.signature,
358 size: block.encryptedData.byteLength,
360 verificationToken: block.verificationToken,
362 thumbnailBlocks: encryptedThumbnailBlocks?.map((thumbnailBlock) => ({
363 index: thumbnailBlock.index,
364 size: thumbnailBlock.encryptedData.byteLength,
365 hash: thumbnailBlock.hash,
366 type: thumbnailBlock.thumbnailType,
368 } satisfies CreateBlockMessage);
371 postProgress(increment: number) {
372 this.worker.postMessage({
375 } satisfies ProgressMessage);
378 postDone(signature: string, signatureAddress: string, xattr: string, photo?: PhotoUpload) {
379 this.worker.postMessage({
385 } satisfies DoneMessage);
388 postNetworkError(error: Error) {
389 this.worker.postMessage({
390 command: 'network_error',
391 error: getSafeErrorObject(error),
392 } satisfies NetworkErrorMessage);
395 postError(error: Error) {
397 error = new Error('Unknown error');
400 this.worker.postMessage({
402 error: getSafeErrorObject(error),
403 } satisfies ErrorMessage);
406 notifyVerificationError(retryHelped: boolean) {
407 this.worker.postMessage({
408 command: 'notify_verification_error',
410 } satisfies NotifyVerificationError);
414 this.worker.postMessage({
415 command: 'heartbeat',
416 } satisfies HeartbeatMessage);
420 this.worker.postMessage({
422 } satisfies WorkerAliveMessage);
425 postLog(message: string) {
426 this.worker.postMessage({
429 } satisfies LogMessage);
434 * UploadWorkerController provides communication between the main thread and
435 * upload web worker. The class ensures type safety as much as possible.
436 * UploadWorkerController is meant to be used on the side of the main thread.
438 export class UploadWorkerController {
441 onCancel: () => void;
443 heartbeatTimeout?: NodeJS.Timeout;
446 * On Chrome, there is no way to know if a worker fails to load.
447 * If the worker is not loaded, it simply won't respond to any messages at all.
449 * The heartbeat could handle this, but since it takes 30 seconds,
450 * it's not particularly great UX. So instead we run a localized timeout, with a
451 * quicker turn-around time in case of failure.
453 workerTimeout?: NodeJS.Timeout;
457 log: (message: string) => void,
467 notifyVerificationError,
469 }: WorkerControllerHandlers
471 this.worker = worker;
472 this.onCancel = onCancel;
474 this.workerTimeout = setTimeout(() => {
476 onError(getRefreshError());
477 }, WORKER_INIT_WAIT_TIME);
479 worker.addEventListener('message', ({ data }: WorkerEvent) => {
480 switch (data.command) {
483 clearTimeout(this.workerTimeout);
485 case 'keys_generated':
486 log('File keys generated');
488 const privateKey = await CryptoProxy.importPrivateKey({
489 binaryKey: data.privateKey,
493 nodeKey: data.nodeKey,
494 nodePassphrase: data.nodePassphrase,
495 nodePassphraseSignature: data.nodePassphraseSignature,
496 contentKeyPacket: data.contentKeyPacket,
497 contentKeyPacketSignature: data.contentKeyPacketSignature,
499 sessionKey: data.sessionKey,
501 })(data).catch((err) => {
502 this.clearHeartbeatTimeout();
506 case 'create_blocks':
507 createBlocks(data.fileBlocks, data.thumbnailBlocks);
510 onProgress(data.increment);
513 this.clearHeartbeatTimeout();
514 finalize(data.signature, data.signatureAddress, data.xattr, data.photo);
516 case 'network_error':
517 onNetworkError(data.error);
520 this.clearHeartbeatTimeout();
522 if (data.error.name === 'RefreshError') {
523 onError(getRefreshError());
527 onError(convertSafeError(data.error));
529 case 'notify_verification_error':
530 notifyVerificationError(data.retryHelped);
533 log('Got heartbeat');
534 this.clearHeartbeatTimeout();
536 this.heartbeatTimeout = setTimeout(() => {
537 log('Heartbeat timeout');
539 notifySentry(new Error('Heartbeat was not received in time'));
541 onHeartbeatTimeout();
543 // Since the worker is stuck, we can terminate it
544 this.worker.terminate();
545 }, HEARTBEAT_WAIT_TIME);
552 // Type linters should prevent this error.
553 throw new Error('Unexpected message');
556 worker.addEventListener('error', (event: ErrorEvent) => {
557 // When a worker fails to load (i.e. 404), an error event is sent without ErrorEvent properties
558 // This isn't properly documented, but basically, some browsers (Firefox) seem to handle
559 // failure to load the worker URL in this way, so this is why we have this condition.
560 // https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent
561 if (!('filename' in event)) {
562 onError(getRefreshError());
566 onError(event.error || new Error(event.message));
570 clearHeartbeatTimeout() {
571 if (this.heartbeatTimeout) {
572 clearTimeout(this.heartbeatTimeout);
577 this.clearHeartbeatTimeout();
578 this.worker.terminate();
585 async postGenerateKeys(addressPrivateKey: PrivateKeyReference | undefined, parentPrivateKey: PrivateKeyReference) {
586 this.worker.postMessage({
587 command: 'generate_keys',
588 addressPrivateKey: addressPrivateKey
589 ? await CryptoProxy.exportPrivateKey({
590 privateKey: addressPrivateKey,
595 parentPrivateKey: await CryptoProxy.exportPrivateKey({
596 privateKey: parentPrivateKey,
600 serverTime: serverTime(),
601 } satisfies GenerateKeysMessage);
613 isForPhotos: boolean;
614 thumbnails?: ThumbnailInfo[];
617 addressPrivateKey: PrivateKeyReference | undefined,
618 addressEmail: string,
619 privateKey: PrivateKeyReference,
620 sessionKey: SessionKey,
621 parentHashKey: Uint8Array,
622 verificationData: VerificationData
624 this.worker.postMessage({
628 isForPhotos: isForPhotos,
631 addressPrivateKey: addressPrivateKey
632 ? await CryptoProxy.exportPrivateKey({
633 privateKey: addressPrivateKey,
639 privateKey: await CryptoProxy.exportPrivateKey({
640 privateKey: privateKey,
647 } satisfies StartMessage);
650 postCreatedBlocks(fileLinks: Link[], thumbnailLinks?: Link[]) {
651 this.worker.postMessage({
652 command: 'created_blocks',
655 } satisfies CreatedBlocksMessage);
659 this.worker.postMessage({
661 } satisfies PauseMessage);
665 this.worker.postMessage({
667 } satisfies ResumeMessage);
671 this.worker.postMessage({
673 } satisfies CloseMessage);