diff --git a/README.md b/README.md index 45541a7..49532f6 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Inspired by [ProtonMail](https://proton.me/blog/encrypted-email) and [ProtonCale - [OpenPGP](https://github.com/ProtonMail/openpgpjs) - [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) +- [Pako](https://github.com/nodeca/pako) ### Features @@ -18,11 +19,11 @@ Inspired by [ProtonMail](https://proton.me/blog/encrypted-email) and [ProtonCale - Create AES-256 keys and encrypt them with PGP public key and sign them with PGP private key. - Encrypt and decrypt any string using AES-256-GCM. - Share and receive data encrypted with other's PGP public key and signed with your PGP private key. +- File encryption, using a 32-bytes key with AES-256 to encrypt every file chunk and using PGP public key to encrypt the key. #### Next -- File encryption, using a 32-bytes key with AES-256 to encrypt every file chunk and using PGP public key to encrypt the key. -- Share encrypted file, encrypting the 32-bytes key with receiver PGP public key. +- Share encrypted file, encrypting the 32-bytes key with receiver PGP public key and signed with sender PGP private key. ### Main flows diff --git a/examples/with-reactjs.tsx b/examples/with-reactjs.tsx index 9fb1d76..4a2ee8b 100644 --- a/examples/with-reactjs.tsx +++ b/examples/with-reactjs.tsx @@ -5,7 +5,7 @@ import { OpenE2EE } from "../lib/open-e2ee"; const userID = "2997e638-b01b-446f-be33-df9ec8b4f206"; export default function Examples() { - const [page, setPage] = useState<"text" | "files">("text"); + const [page, setPage] = useState<"text" | "files">("files"); return (


{page === "text" && } + {page === "files" && }
); } @@ -157,3 +159,56 @@ function TextExample() { ); } + +const FilesPage = () => { + const [passphrase] = useState("passphrase-long-super-long"); + const openE2EESvc = useMemo( + () => new OpenE2EE(userID, passphrase), + [passphrase] + ); + const [encryptKey, setEncryptKey] = useState(""); + + useEffect(() => { + (async () => { + await openE2EESvc.build(); + console.log("open-e2ee built"); + })(); + }, []); + + const onEncryptFile = async (e: ChangeEvent) => { + const files = (e.target as HTMLInputElement).files; + if (!files?.length) return console.log("no files selected"); + const { encryptedKey } = await openE2EESvc.encryptFile(files[0]); + setEncryptKey(encryptedKey); + }; + + const onDecryptFile = async (e: ChangeEvent) => { + const files = (e.target as HTMLInputElement).files; + if (!files?.length) return console.log("no files selected"); + await openE2EESvc.decryptFile(encryptKey, files[0]); + }; + + return ( +
+ + +
+
+
+ + +
+ ); +}; 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",