) => {
+ const files = (e.target as HTMLInputElement).files;
+ if (!files?.length) return console.log("no files selected");
+ await openE2EESvc.decryptFile(encryptKey, files[0]);
+ };
+
+ return (
+
+ Encrypt file:
+
+
+
+
+ Decrypt file:
+
+
+ );
+};
diff --git a/lib/compression.ts b/lib/compression.ts
new file mode 100644
index 0000000..c19f0af
--- /dev/null
+++ b/lib/compression.ts
@@ -0,0 +1,8 @@
+import pako from "pako";
+
+export const compressString = (data: string): Uint8Array => pako.deflate(data);
+
+export const decompress = (compressesData: Uint8Array): Uint8Array =>
+ pako.inflate(compressesData);
+
+export const compress = (data: Uint8Array): Uint8Array => pako.deflate(data);
diff --git a/lib/crypto.ts b/lib/crypto.ts
index 96ecec7..8a89f39 100644
--- a/lib/crypto.ts
+++ b/lib/crypto.ts
@@ -1,3 +1,4 @@
+import { compress, compressString, decompress } from "./compression";
import {
base64StringToUint8Array,
uint8ArrayToBase64String,
@@ -47,10 +48,18 @@ export class Crypto {
static encrypt = tryCatch(
"crypto.encrypt",
- async (key: string, data: string): Promise => {
+ async (
+ key: string,
+ data: string,
+ config: EncryptConfig = { compression: false }
+ ): Promise => {
const iv = this.createRandomValue(this.aesIVLength);
const keyObj = await this.importSymmetricKey(key);
- const dataBuf = stringToUint8Array(data);
+
+ const dataBuf = config.compression
+ ? compressString(data)
+ : stringToUint8Array(data);
+
const encryptedData = await crypto.subtle.encrypt(
{ name: this.encryptionAlgorithm, iv },
keyObj,
@@ -64,7 +73,11 @@ export class Crypto {
static decrypt = tryCatch(
"crypto.decrypt",
- async (key: string, encryptedData: string): Promise => {
+ async (
+ key: string,
+ encryptedData: string,
+ config: EncryptConfig = { compression: false }
+ ): Promise => {
const encryptedBuffer = base64StringToUint8Array(encryptedData);
const iv = encryptedBuffer.slice(0, this.aesIVLength);
const cipher = encryptedBuffer.slice(
@@ -77,14 +90,58 @@ export class Crypto {
keyObj,
cipher
);
- return uint8ArrayToString(new Uint8Array(decryptedData));
+ let decryptedBuffer = new Uint8Array(decryptedData);
+ if (config.compression) decryptedBuffer = decompress(decryptedBuffer);
+ return uint8ArrayToString(decryptedBuffer);
+ }
+ );
+
+ static encryptFile = tryCatch(
+ "crypto.encryptFile",
+ async (key: string, data: Uint8Array): Promise => {
+ const iv = this.createRandomValue(this.aesIVLength);
+ const keyObj = await this.importSymmetricKey(key);
+
+ const dataBuf = compress(data);
+
+ const encryptedData = await crypto.subtle.encrypt(
+ { name: this.encryptionAlgorithm, iv },
+ keyObj,
+ dataBuf
+ );
+ return mergeUint8Arrays(iv, new Uint8Array(encryptedData));
+ }
+ );
+
+ static decryptFile = tryCatch(
+ "crypto.decryptFile",
+ async (key: string, encryptedData: string): Promise => {
+ const encryptedBuffer = stringToUint8Array(encryptedData);
+ const iv = encryptedBuffer.slice(0, this.aesIVLength);
+ const cipher = encryptedBuffer.slice(
+ this.aesIVLength,
+ encryptedBuffer.length
+ );
+ const keyObj = await this.importSymmetricKey(key);
+ const decryptedData = await crypto.subtle.decrypt(
+ { name: this.encryptionAlgorithm, iv },
+ keyObj,
+ cipher
+ );
+ return decompress(new Uint8Array(decryptedData));
}
);
static digest = tryCatch("crypto.digest", async (message: string) => {
- if (!message) return "";
+ if (!message) {
+ return "";
+ }
const msgUint8 = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
return uint8ArrayToBase64String(new Uint8Array(hashBuffer));
});
}
+
+interface EncryptConfig {
+ compression: boolean;
+}
diff --git a/lib/file/encryption.ts b/lib/file/encryption.ts
new file mode 100644
index 0000000..775b577
--- /dev/null
+++ b/lib/file/encryption.ts
@@ -0,0 +1,143 @@
+import type { Sha1 } from '@openpgp/asmcrypto.js/dist_es8/hash/sha1/sha1';
+
+import { CryptoProxy, PrivateKeyReference, SessionKey } from '@proton/crypto';
+import { FILE_CHUNK_SIZE } from '@proton/shared/lib/drive/constants';
+import { generateContentHash } from '@proton/shared/lib/keys/driveKeys';
+
+import ChunkFileReader from '../ChunkFileReader';
+import { MAX_BLOCK_VERIFICATION_RETRIES } from '../constants';
+import { EncryptedBlock, ThumbnailEncryptedBlock } from '../interface';
+import { ThumbnailInfo } from '../media';
+import { Verifier } from './interface';
+
+/**
+ * generateEncryptedBlocks generates blocks for the specified file.
+ * Each block is chunked to FILE_CHUNK_SIZE and encrypted, counting index
+ * from one.
+ */
+export async function* generateEncryptedBlocks(
+ file: File,
+ addressPrivateKey: PrivateKeyReference,
+ privateKey: PrivateKeyReference,
+ sessionKey: SessionKey,
+ postNotifySentry: (e: Error) => void,
+ hashInstance: Sha1,
+ verifier: Verifier
+): AsyncGenerator {
+ let index = 1;
+ const reader = new ChunkFileReader(file, FILE_CHUNK_SIZE);
+ while (!reader.isEOF()) {
+ const chunk = await reader.readNextChunk();
+
+ hashInstance.process(chunk);
+
+ yield await encryptBlock(index++, chunk, addressPrivateKey, privateKey, sessionKey, verifier, postNotifySentry);
+ }
+}
+
+/**
+ * generateEncryptedThumbnailsBlocks generates blocks for the specified thumbnails
+ * Each thumbnail will fit on a single block.
+ * Index is not taken in consideration for Thumbnails, so we can start it at 0
+ */
+export async function* generateThumbnailEncryptedBlocks(
+ thumbnails: ThumbnailInfo[] | undefined,
+ addressPrivateKey: PrivateKeyReference,
+ sessionKey: SessionKey
+): AsyncGenerator {
+ if (!!thumbnails?.length) {
+ let index = 0;
+ for (let i = 0; i < thumbnails?.length; i++) {
+ yield await encryptThumbnail(index++, addressPrivateKey, sessionKey, thumbnails[i]);
+ }
+ }
+}
+
+async function encryptThumbnail(
+ index: number,
+ addressPrivateKey: PrivateKeyReference,
+ sessionKey: SessionKey,
+ thumbnail: ThumbnailInfo
+): Promise {
+ const { message: encryptedData } = await CryptoProxy.encryptMessage({
+ binaryData: thumbnail.thumbnailData,
+ sessionKey,
+ signingKeys: addressPrivateKey,
+ format: 'binary',
+ detached: false,
+ });
+ const hash = (await generateContentHash(encryptedData)).BlockHash;
+ return {
+ index,
+ // Original size is used only for showing progress. We don't want to
+ // include thumbnail to it, otherwise it would show more than 100%.
+ originalSize: 0,
+ encryptedData,
+ hash,
+ thumbnailType: thumbnail.thumbnailType,
+ };
+}
+
+async function encryptBlock(
+ index: number,
+ chunk: Uint8Array,
+ addressPrivateKey: PrivateKeyReference,
+ privateKey: PrivateKeyReference,
+ sessionKey: SessionKey,
+ verifyBlock: Verifier,
+ postNotifySentry: (e: Error) => void
+): Promise {
+ const tryEncrypt = async (retryCount: number): Promise => {
+ // Generate the encrypted block
+ const { message: encryptedData, signature } = await CryptoProxy.encryptMessage({
+ binaryData: chunk,
+ sessionKey,
+ signingKeys: addressPrivateKey,
+ format: 'binary',
+ detached: true,
+ });
+
+ // IMPORTANT!
+ // Hashing must happen BEFORE verifying.
+ // If verification is successful, then we know the hash corresponds
+ // to something we can decrypt. If hashing was placed after verification,
+ // the cyphertext could get corrupted after verification succeeds,
+ // which would create an incorrect digest that would look "correct" to the server.
+ const hash = (await generateContentHash(encryptedData)).BlockHash;
+ let verificationToken;
+
+ try {
+ verificationToken = await verifyBlock(encryptedData);
+ } catch (e) {
+ // Only trace the error to sentry once
+ if (retryCount === 0) {
+ postNotifySentry(new Error('Verification failed and retried', { cause: { e } }));
+ }
+
+ if (retryCount < MAX_BLOCK_VERIFICATION_RETRIES) {
+ return tryEncrypt(retryCount + 1);
+ }
+
+ // Give up after max retries reached, something's wrong
+ throw new Error('Upload failed: Verification of data failed', { cause: { e } });
+ }
+
+ // Encrypt the block signature after verification
+ const { message: encryptedSignature } = await CryptoProxy.encryptMessage({
+ binaryData: signature,
+ sessionKey,
+ encryptionKeys: privateKey,
+ });
+
+ return {
+ index,
+ originalSize: chunk.length,
+ encryptedData,
+ hash,
+ signature: encryptedSignature,
+ verificationToken,
+ };
+ };
+
+ return tryEncrypt(0);
+}
diff --git a/lib/file/file.utils.ts b/lib/file/file.utils.ts
new file mode 100644
index 0000000..c8523fa
--- /dev/null
+++ b/lib/file/file.utils.ts
@@ -0,0 +1,123 @@
+import {
+ base64StringToUint8Array,
+ uint8ArrayToString,
+} from "../encoding.utils";
+
+/**
+ * Convert file to encoded base 64 string
+ */
+export const fileToBase64 = async (
+ file: Blob,
+ isValid: (file: Blob) => boolean = () => true
+) => {
+ if (file && !isValid(file)) {
+ throw new Error("Invalid file format");
+ }
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = ({ target }) => {
+ if (!target?.result) {
+ return reject(new Error("Invalid file"));
+ }
+ resolve(target.result as string);
+ };
+ reader.onerror = reject;
+ reader.onabort = reject;
+ reader.readAsDataURL(file);
+ });
+};
+
+/**
+ * Read the content of a blob and returns its value as a buffer
+ */
+export const readFileAsBuffer = (file: File) => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = ({ target }) => {
+ if (!target?.result) {
+ return reject(new Error("Invalid file"));
+ }
+ resolve(target.result as ArrayBuffer);
+ };
+ reader.onerror = reject;
+ reader.onabort = reject;
+ reader.readAsArrayBuffer(file);
+ });
+};
+
+/**
+ * Read the content of a blob and returns its value as a text string
+ */
+export const readFileAsString = (file: File, encoding?: string) => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = ({ target }) => {
+ if (!target?.result) {
+ return reject(new Error("Invalid file"));
+ }
+ resolve(target.result as string);
+ };
+ reader.onerror = reject;
+ reader.onabort = reject;
+ reader.readAsText(file, encoding);
+ });
+};
+
+/**
+ * Read the content of a blob and returns its value as a binary string.
+ * Not using readAsBinaryString because it's deprecated.
+ */
+export const readFileAsBinaryString = async (file: File) => {
+ const arrayBuffer = await readFileAsBuffer(file);
+ // eslint-disable-next-line new-cap
+ return uint8ArrayToString(new Uint8Array(arrayBuffer));
+};
+
+/**
+ * Convert a blob url to the matching blob
+ * @link https://stackoverflow.com/a/42508185
+ */
+export const blobURLtoBlob = (url: string) => {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open("GET", url);
+ xhr.responseType = "blob";
+ xhr.onerror = reject;
+ xhr.onload = () => {
+ if (xhr.status === 200) {
+ return resolve(xhr.response);
+ }
+ reject(xhr);
+ };
+ xhr.send();
+ });
+};
+
+/**
+ * Read the base64 portion of a data url.
+ */
+export const readDataUrl = (url = "") => {
+ const error = "The given url is not a data url.";
+
+ if (url.substring(0, 5) !== "data:") {
+ throw new Error(error);
+ }
+
+ const [, base64] = url.split(",");
+ if (!base64) {
+ throw new Error(error);
+ }
+
+ return base64StringToUint8Array(base64);
+};
+
+/**
+ * Split a filename into [name, extension]
+ */
+export const splitExtension = (filename = "") => {
+ const endIdx = filename.lastIndexOf(".");
+ if (endIdx === -1) {
+ return [filename, ""];
+ }
+ return [filename.slice(0, endIdx), filename.slice(endIdx + 1)];
+};
diff --git a/lib/file/fileChunkreader.ts b/lib/file/fileChunkreader.ts
new file mode 100644
index 0000000..2c4e016
--- /dev/null
+++ b/lib/file/fileChunkreader.ts
@@ -0,0 +1,182 @@
+import { Crypto } from "../crypto";
+import { uint8ArrayToBase64String } from "../encoding.utils";
+import { kbToBytes, mbToBytes } from "../fileSizeUtils";
+
+export const FOLDER_PAGE_SIZE = 150;
+export const BATCH_REQUEST_SIZE = 50;
+export const FILE_CHUNK_SIZE = mbToBytes(5); // kbToBytes(1); //;
+
+type ReadAs = "buffer" | "text" | "data-url";
+
+export class FileEncryption {
+ private blob: Blob;
+ private chunkSize: number;
+ private offset = 0;
+
+ constructor(file: Blob, chunkSize: number = FILE_CHUNK_SIZE) {
+ this.blob = file;
+ this.chunkSize = chunkSize;
+ }
+
+ private isEOF() {
+ return this.offset >= this.blob.size;
+ }
+
+ private async next(readAs: ReadAs) {
+ const fileReader = new FileReader();
+ const blob = this.blob.slice(this.offset, this.offset + this.chunkSize);
+
+ return new Promise((resolve, reject) => {
+ fileReader.onload = async (e) => {
+ if (!e.target || e.target?.error) {
+ return reject(
+ e.target?.error || new Error("Cannot open file for reading")
+ );
+ }
+ const result = new Uint8Array(e.target.result as ArrayBuffer);
+ this.offset += this.chunkSize;
+ resolve(result);
+ };
+
+ fileReader.onprogress = (event) => {
+ if (event.loaded && event.total) {
+ const percent = (event.loaded / event.total) * 100;
+ console.log(`Progress: ${Math.round(percent)}`);
+ }
+ };
+
+ this.readAs(readAs, fileReader, blob);
+ });
+ }
+
+ async readInChunks(readAs: ReadAs, fn: (chunk: Uint8Array) => any) {
+ while (!this.isEOF()) {
+ const chunk = await this.next(readAs);
+ await fn(chunk);
+ }
+ }
+
+ private buffer = "";
+
+ async nextEncrypted(readAs: ReadAs, separator: string): Promise {
+ while (!this.buffer.includes(separator)) {
+ let next = await this.next(readAs);
+ // if (readAs === "data-url") {
+ // next = next.replace("data:application/octet-stream;base64,", "");
+ // }
+ // this.buffer += next.length ? atob(next) : next;
+ this.buffer += uint8ArrayToBase64String(next);
+ if (!next.length) {
+ break;
+ }
+ }
+
+ if (!this.buffer.includes(separator)) {
+ return this.buffer;
+ }
+
+ const newchunkBeginPosition = this.buffer.indexOf(separator);
+ const newchunkEndPosition = newchunkBeginPosition + separator.length;
+ const line = this.buffer.substring(0, newchunkBeginPosition);
+ this.buffer = this.buffer.substring(newchunkEndPosition);
+ return line;
+ }
+
+ async readEncryptedInChunks(
+ readAs: ReadAs,
+ separator: string,
+ fn: (encryptedChunk: string) => any
+ ) {
+ while (!this.isEOF()) {
+ const chunk = await this.nextEncrypted(readAs, separator);
+ await fn(chunk);
+ }
+ }
+
+ saveChunkedFile = (name: string, blob: Blob, separator: string) => {
+ const anchor = document.createElement("a");
+ // anchor.href =
+ // "data:application/octet-stream;base64," +
+ // btoa(
+ // chunks
+ // .map((c) => c.replace("data:application/octet-stream;base64,", ""))
+ // .map((c) => atob(c))
+ // .join("")
+ // );
+ anchor.href = URL.createObjectURL(blob);
+ anchor.download = name;
+ anchor.click();
+
+ // const blob = new Blob(decryptedChunks, {
+ // type: "application/octet-stream",
+ // });
+ // const blobURL = URL.createObjectURL(blob);
+ // const anchor = document.createElement("a");
+ // anchor.download =
+ // "dec-chunk." + encryptedFile.name.replace("-chunk.enc", "");
+ // anchor.href = blobURL;
+ // anchor.click();
+ // URL.revokeObjectURL(await blob.text());
+
+ return {};
+ };
+
+ saveEncryptedChunkedFile = async (
+ name: string,
+ encryptedBlob: Blob,
+ separator: string
+ ) => {
+ // const blob = await fetch(
+ // `data:plain/text;base64,${btoa(chunks.join(separator))}`
+ // ).then((res) => res.blob());
+
+ const anchor = document.createElement("a");
+ anchor.download = name;
+ anchor.target = "_blank";
+ // anchor.href = "data:application/octet-stream," + encryptedBlob.join(separator);
+ anchor.href = URL.createObjectURL(encryptedBlob);
+ anchor.click();
+ URL.revokeObjectURL(anchor.href);
+
+ // // const blob = new Blob(encryptedChunks, {
+ // // type: "application/octet-stream",
+ // // });
+ // // const blobURL = URL.createObjectURL(blob);
+ // const anchor = document.createElement("a");
+ // anchor.download = file.name + ".enc";
+ // anchor.href =
+ // "data:application/octet-stream," +
+ // btoa(
+ // encryptedChunks.join(
+ // this.encryptedChunkSeparator === pgpMessageEnd
+ // ? ""
+ // : this.encryptedChunkSeparator
+ // )
+ // );
+ // // anchor.href = blobURL;
+ // anchor.click();
+ // // URL.revokeObjectURL(await blob.text());
+
+ // FileSaver.saveAs(blob, name);
+ };
+
+ computeContentHash = async (contentChunks: string[]) => {
+ const hashes = await Promise.all(
+ contentChunks.map((content) => Crypto.digest(content))
+ );
+ return hashes.join("_");
+ };
+
+ private readAs(readAs: ReadAs, fileReader: FileReader, blob: Blob) {
+ switch (readAs) {
+ case "text":
+ fileReader.readAsText(blob);
+ break;
+ case "data-url":
+ fileReader.readAsDataURL(blob);
+ break;
+ default:
+ fileReader.readAsArrayBuffer(blob);
+ }
+ }
+}
diff --git a/lib/fileSizeUtils/fileSizeUtils.test.ts b/lib/fileSizeUtils/fileSizeUtils.test.ts
new file mode 100644
index 0000000..41f1108
--- /dev/null
+++ b/lib/fileSizeUtils/fileSizeUtils.test.ts
@@ -0,0 +1,102 @@
+import {
+ bytesToKb,
+ bytesToMb,
+ bytesToGb,
+ bytesToTb,
+ kbToBytes,
+ mbToBytes,
+ gbToBytes,
+ tbToBytes,
+ kbToMb,
+ mbToGb,
+ gbToTb,
+ mbToKb,
+ gbToMb,
+ tbToGb,
+ bytesToHumanReadable
+} from './fileSizeUtils';
+
+describe('File size Utils', () => {
+ // Upward conversion functions from bytes
+ test('bytesToKb', () => {
+ expect(bytesToKb(1000)).toBe(1);
+ });
+
+ test('bytesToMb', () => {
+ expect(bytesToMb(1000_000)).toBe(1);
+ });
+
+ test('bytesToGb', () => {
+ expect(bytesToGb(1000_000_000)).toBe(1);
+ });
+
+ test('bytesToTb', () => {
+ expect(bytesToTb(1000_000_000_000)).toBe(1);
+ });
+
+ // Downward conversion functions to bytes
+ test('kbToBytes', () => {
+ expect(kbToBytes(1)).toBe(1000);
+ });
+
+ test('mbToBytes', () => {
+ expect(mbToBytes(1)).toBe(1000_000);
+ });
+
+ test('gbToBytes', () => {
+ expect(gbToBytes(1)).toBe(1000_000_000);
+ });
+
+ test('tbToBytes', () => {
+ expect(tbToBytes(1)).toBe(1000_000_000_000);
+ });
+
+ // Upward conversion functions between units
+ test('kbToMb', () => {
+ expect(kbToMb(1000)).toBe(1);
+ });
+
+ test('mbToGb', () => {
+ expect(mbToGb(1000)).toBe(1);
+ });
+
+ test('gbToTb', () => {
+ expect(gbToTb(1000)).toBe(1);
+ });
+
+ // Downward conversion functions between units
+ test('mbToKb', () => {
+ expect(mbToKb(1)).toBe(1000);
+ });
+
+ test('gbToMb', () => {
+ expect(gbToMb(1)).toBe(1000);
+ });
+
+ test('tbToGb', () => {
+ expect(tbToGb(1)).toBe(1000);
+ });
+
+ // Convert bytes to a human-readable string
+ test('bytesToHumanReadable with default decimalPlaces', () => {
+ expect(bytesToHumanReadable(0)).toBe('0 KB');
+ expect(bytesToHumanReadable(500)).toBe('1 KB');
+ expect(bytesToHumanReadable(1500)).toBe('2 KB');
+ expect(bytesToHumanReadable(1_500_000)).toBe('1.5 MB');
+ expect(bytesToHumanReadable(1_500_000_000)).toBe('1.5 GB');
+ expect(bytesToHumanReadable(1_500_000_000_000)).toBe('1.5 TB');
+
+ // Verified that a value exactly on the line is handled correctly
+ expect(bytesToHumanReadable(1_000_000_000_000, 0)).toBe('1 TB');
+ });
+
+ test('bytesToHumanReadable with custom decimalPlaces', () => {
+ expect(bytesToHumanReadable(1500, 2)).toBe('1.50 KB');
+ expect(bytesToHumanReadable(1_500_000, 3)).toBe('1.500 MB');
+ expect(bytesToHumanReadable(1_500_000_000, 0)).toBe('2 GB');
+ expect(bytesToHumanReadable(1_500_000_000_000, 2)).toBe('1.50 TB');
+
+ // Verified that a value exactly on the line is handled correctly
+ expect(bytesToHumanReadable(1_000_000_000_000, 0)).toBe('1 TB');
+ });
+});
diff --git a/lib/fileSizeUtils/fileSizeUtils.ts b/lib/fileSizeUtils/fileSizeUtils.ts
new file mode 100644
index 0000000..d5e9048
--- /dev/null
+++ b/lib/fileSizeUtils/fileSizeUtils.ts
@@ -0,0 +1,80 @@
+/**
+ * Scale factor to convert from each byte unit to the next
+ * e.g. 1000 bytes = 1 kilobyte, 1000 kilobytes = 1 megabyte, etc.
+ */
+export const BYTE_SCALE_FACTOR = 1000;
+
+/**
+ * Upward conversion functions from bytes
+ */
+
+export const bytesToKb = (bytes: number) => bytes / BYTE_SCALE_FACTOR;
+
+export const bytesToMb = (bytes: number) => bytesToKb(bytes) / BYTE_SCALE_FACTOR;
+
+export const bytesToGb = (bytes: number) => bytesToMb(bytes) / BYTE_SCALE_FACTOR;
+
+export const bytesToTb = (bytes: number) => bytesToGb(bytes) / BYTE_SCALE_FACTOR;
+
+/**
+ * Downward conversion functions to bytes
+ */
+
+export const kbToBytes = (kb: number) => kb * BYTE_SCALE_FACTOR;
+
+export const mbToBytes = (mb: number) => kbToBytes(mb) * BYTE_SCALE_FACTOR;
+
+export const gbToBytes = (gb: number) => mbToBytes(gb) * BYTE_SCALE_FACTOR;
+
+export const tbToBytes = (tb: number) => gbToBytes(tb) * BYTE_SCALE_FACTOR;
+
+/**
+ * Upward conversion functions between units
+ */
+
+export const kbToMb = (kb: number) => kb / BYTE_SCALE_FACTOR;
+
+export const mbToGb = (mb: number) => mb / BYTE_SCALE_FACTOR;
+
+export const gbToTb = (gb: number) => gb / BYTE_SCALE_FACTOR;
+
+/**
+ * Downward conversion functions between units
+ */
+
+export const mbToKb = (mb: number) => mb * BYTE_SCALE_FACTOR;
+
+export const gbToMb = (gb: number) => gb * BYTE_SCALE_FACTOR;
+
+export const tbToGb = (tb: number) => tb * BYTE_SCALE_FACTOR;
+
+/**
+ * Convert bytes to a human-readable string
+ */
+
+export const bytesToHumanReadable = (bytes: number, decimalPlaces?: number) => {
+ // If bytes is less than 1 KB, we'll default to 1 KB instead of showing bytes
+ if (!bytes) {
+ return '0 KB';
+ }
+
+ // If bytes is less than 1 KB, we'll default to 1 KB instead of showing bytes
+ if (bytes < BYTE_SCALE_FACTOR) {
+ return '1 KB';
+ }
+
+ if (bytes < mbToBytes(1)) {
+ // Since KB are our smallest display unit, we'll default rounding to 0 decimal places unless specified
+ return `${bytesToKb(bytes).toFixed(decimalPlaces ?? 0)} KB`;
+ }
+
+ if (bytes < gbToBytes(1)) {
+ return `${bytesToMb(bytes).toFixed(decimalPlaces ?? 1)} MB`;
+ }
+
+ if (bytes < tbToBytes(1)) {
+ return `${bytesToGb(bytes).toFixed(decimalPlaces ?? 1)} GB`;
+ }
+
+ return `${bytesToTb(bytes).toFixed(decimalPlaces ?? 1)} TB`;
+};
diff --git a/lib/fileSizeUtils/index.ts b/lib/fileSizeUtils/index.ts
new file mode 100644
index 0000000..66dac80
--- /dev/null
+++ b/lib/fileSizeUtils/index.ts
@@ -0,0 +1 @@
+export * from './fileSizeUtils';
diff --git a/lib/open-e2ee.ts b/lib/open-e2ee.ts
index 0e4852b..b7b5b9e 100644
--- a/lib/open-e2ee.ts
+++ b/lib/open-e2ee.ts
@@ -8,6 +8,12 @@ import {
EncryptItemOut,
} from "./models";
import { PGPPrivateKey, PGPPublicKey, PGP } from "./pgp";
+import { FileEncryption } from "./file/fileChunkreader";
+import {
+ base64StringToUint8Array,
+ stringToUint8Array,
+ uint8ArrayToBase64String,
+} from "./encoding.utils";
export class OpenE2EE {
private passphrase: string;
@@ -240,6 +246,62 @@ export class OpenE2EE {
return { shareKey, data };
};
+ private encryptedChunkSeparator = "_ENDCHUNK_";
+ encryptFile = async (file: File) => {
+ this.needsFeatures("files");
+
+ const { shareKey, encryptedShareKey } = await PGP.generateShareKey(
+ this.publicKey
+ );
+
+ let encryptedChunks: Uint8Array[] = [];
+
+ const fileEncryptor = new FileEncryption(file);
+ await fileEncryptor.readInChunks("buffer", async (chunk: Uint8Array) => {
+ const encChunk = await PGP.encryptFile(
+ shareKey,
+ uint8ArrayToBase64String(chunk)
+ );
+ encryptedChunks.push(base64StringToUint8Array(encChunk));
+ });
+
+ encryptedChunks = insertAfterEachItem(
+ encryptedChunks,
+ this.encryptedChunkSeparator
+ );
+
+ await fileEncryptor.saveEncryptedChunkedFile(
+ file.name + ".enc",
+ new Blob(encryptedChunks, { type: file.type }),
+ this.encryptedChunkSeparator
+ );
+
+ return { encryptedKey: encryptedShareKey };
+ };
+
+ decryptFile = async (encryptedKey: string, encryptedFile: File) => {
+ this.needsFeatures("files");
+
+ const shareKey = await PGP.decryptShareKey(this.privateKey, encryptedKey);
+
+ const decryptedChunks: Uint8Array[] = [];
+
+ const fileEncryptor = new FileEncryption(encryptedFile);
+ await fileEncryptor.readEncryptedInChunks(
+ "buffer",
+ this.encryptedChunkSeparator,
+ async (encChunk: string) => {
+ const chunk = await PGP.decryptFile(shareKey, encChunk);
+ decryptedChunks.push(stringToUint8Array(chunk));
+ }
+ );
+ await fileEncryptor.saveChunkedFile(
+ "dec." + encryptedFile.name.replace(".enc", ""),
+ new Blob(decryptedChunks, { type: encryptedFile.type }),
+ ""
+ );
+ };
+
private needsFeatures = (neededFeatures: Feature[] | Feature) => {
if (typeof neededFeatures === "string") {
neededFeatures = [neededFeatures];
@@ -251,7 +313,29 @@ export class OpenE2EE {
throw Error(`${neededFeatures.join(",")} features need to be enabled`);
}
};
- private getFeature = (feature: Feature) => this.features.includes(feature);
+ private getFeature = (feature: Feature) =>
+ this.features.find((f) => f === feature);
+}
+
+function insertAfterEachItem(
+ originalArray: Uint8Array[],
+ itemToInsert: string
+) {
+ // Create a new array to store the modified items
+ const newArray: Uint8Array[] = [];
+
+ // Iterate through the original array
+ originalArray.forEach((item, index) => {
+ // Add the current item
+ newArray.push(item);
+
+ // Add the item to insert after each actual item (except the last one)
+ if (index < originalArray.length - 1) {
+ newArray.push(stringToUint8Array(itemToInsert));
+ }
+ });
+
+ return newArray;
}
export type Feature = "share" | "files";
diff --git a/lib/pgp.ts b/lib/pgp.ts
index 4e056c8..d8a4bb3 100644
--- a/lib/pgp.ts
+++ b/lib/pgp.ts
@@ -118,13 +118,19 @@ export class PGP {
async (
signingKey: PGPPrivateKey,
key: string,
- data: string
+ data: string,
+ config: EncryptConfig = { compression: false }
): Promise => {
const message = await openpgp.createMessage({ text: data });
const encrypted = await openpgp.encrypt({
message,
passwords: key,
signingKeys: signingKey,
+ config: {
+ preferredCompressionAlgorithm: config.compression
+ ? openpgp.enums.compression.zlib
+ : openpgp.enums.compression.uncompressed,
+ },
});
return encrypted as string;
}
@@ -147,4 +153,13 @@ export class PGP {
return decrypted.data as string;
}
);
+
+ static encryptFile = (key: string, data: string) =>
+ this.encrypt(undefined, key, data, { compression: true });
+
+ static decryptFile = (key: string, data: string) => this.decrypt(key, data);
+}
+
+export interface EncryptConfig {
+ compression: boolean;
}
diff --git a/package-lock.json b/package-lock.json
index 25fc0c5..fcb2f74 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,21 +1,23 @@
{
"name": "open-e2ee",
- "version": "1.0.0",
+ "version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-e2ee",
- "version": "1.0.0",
+ "version": "1.1.0",
"license": "MIT",
"dependencies": {
- "openpgp": "^5.11.0"
+ "openpgp": "^5.11.0",
+ "pako": "^2.1.0"
},
"devDependencies": {
"@babel/preset-typescript": "^7.23.3",
"@jest/globals": "^29.7.0",
"@peculiar/webcrypto": "^1.4.3",
"@types/jest": "^29.5.8",
+ "@types/pako": "^2.0.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
@@ -1560,6 +1562,12 @@
"undici-types": "~5.26.4"
}
},
+ "node_modules/@types/pako": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.3.tgz",
+ "integrity": "sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q==",
+ "dev": true
+ },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -4710,6 +4718,11 @@
"node": ">=6"
}
},
+ "node_modules/pako": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
+ },
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
diff --git a/package.json b/package.json
index db9d62a..35068ea 100644
--- a/package.json
+++ b/package.json
@@ -23,13 +23,15 @@
"test-coverage": "jest --coverage"
},
"dependencies": {
- "openpgp": "^5.11.0"
+ "openpgp": "^5.11.0",
+ "pako": "^2.1.0"
},
"devDependencies": {
"@babel/preset-typescript": "^7.23.3",
"@jest/globals": "^29.7.0",
"@peculiar/webcrypto": "^1.4.3",
"@types/jest": "^29.5.8",
+ "@types/pako": "^2.0.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",