From 4178b7960e51b13c3c78269278a3240d5ac453af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20C=C3=A1nepa?= <8711973+bruncanepa@users.noreply.github.com> Date: Fri, 1 Dec 2023 18:54:50 -0300 Subject: [PATCH 1/3] Working with small files --- README.md | 5 +- lib/compression.ts | 6 + lib/crypto.ts | 59 +++++++-- lib/encoding.utils.ts | 85 ++++++++---- lib/file/encryption.ts | 143 ++++++++++++++++++++ lib/file/file.utils.ts | 123 ++++++++++++++++++ lib/file/fileChunkreader.ts | 165 ++++++++++++++++++++++++ lib/fileSizeUtils/fileSizeUtils.test.ts | 102 +++++++++++++++ lib/fileSizeUtils/fileSizeUtils.ts | 80 ++++++++++++ lib/fileSizeUtils/index.ts | 1 + lib/open-e2ee.ts | 103 +++++++++++++++ lib/pgp.ts | 21 ++- package-lock.json | 19 ++- package.json | 4 +- 14 files changed, 869 insertions(+), 47 deletions(-) create mode 100644 lib/compression.ts create mode 100644 lib/file/encryption.ts create mode 100644 lib/file/file.utils.ts create mode 100644 lib/file/fileChunkreader.ts create mode 100644 lib/fileSizeUtils/fileSizeUtils.test.ts create mode 100644 lib/fileSizeUtils/fileSizeUtils.ts create mode 100644 lib/fileSizeUtils/index.ts diff --git a/README.md b/README.md index ab1ad4f..debe42a 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 your PGP private key. - Encrypt and decrypt any string using AES-256. - 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/lib/compression.ts b/lib/compression.ts new file mode 100644 index 0000000..069fc73 --- /dev/null +++ b/lib/compression.ts @@ -0,0 +1,6 @@ +import pako from "pako"; + +export const compress = (data: string): Uint8Array => pako.deflate(data); + +export const decompress = (compressesData: Uint8Array): Uint8Array => + pako.inflate(compressesData); diff --git a/lib/crypto.ts b/lib/crypto.ts index 71f0ecd..4a84985 100644 --- a/lib/crypto.ts +++ b/lib/crypto.ts @@ -1,6 +1,7 @@ +import { compress, decompress } from "./compression"; import { - arrayToHexString, - hexStringToArray, + base64StringToUint8Array, + uint8ArrayToBase64String, mergeUint8Arrays, stringToUint8Array, uint8ArrayToString, @@ -18,7 +19,7 @@ export class CryptoService { (key: string): Promise => crypto.subtle.importKey( "raw", - hexStringToArray(key), + base64StringToUint8Array(key), this.encryptionAlgorithm, true, ["decrypt", "encrypt"] @@ -30,15 +31,24 @@ export class CryptoService { 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 = config.compression + ? compress(data) + : stringToUint8Array(data); + const encryptedData = await crypto.subtle.encrypt( { name: this.encryptionAlgorithm, iv }, keyObj, - stringToUint8Array(data) + dataBuf ); - return arrayToHexString( + return uint8ArrayToBase64String( mergeUint8Arrays(iv, new Uint8Array(encryptedData)) ); } @@ -46,8 +56,12 @@ export class CryptoService { decrypt = tryCatch( "crypto.decrypt", - async (key: string, encryptedData: string): Promise => { - const encryptedBuffer = hexStringToArray(encryptedData); + 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( this.aesIVLength, @@ -59,7 +73,34 @@ export class CryptoService { keyObj, cipher ); - return uint8ArrayToString(new Uint8Array(decryptedData)); + let decryptedBuffer = new Uint8Array(decryptedData); + if (config.compression) decryptedBuffer = decompress(decryptedBuffer); + return uint8ArrayToString(decryptedBuffer); } ); + + encryptFile = (key: string, data: string) => + this.encrypt(key, data, { compression: true }); + + decryptFile = (key: string, data: string) => + this.decrypt(key, data, { compression: true }); + + /** + * Hash with sha256 a message + * @param message data to hash + * @returns sha256 hash base64 encoded + */ + sha256 = async (message: string) => { + if (!message) { + return ""; + } + const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array + const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8); // hash the message + const hashBase64 = uint8ArrayToBase64String(new Uint8Array(hashBuffer)); // convert buffer to base64 + return hashBase64; + }; +} + +interface EncryptConfig { + compression: boolean; } diff --git a/lib/encoding.utils.ts b/lib/encoding.utils.ts index d51ef2e..ac83129 100644 --- a/lib/encoding.utils.ts +++ b/lib/encoding.utils.ts @@ -4,34 +4,6 @@ export const stringToUint8Array = (value: string): Uint8Array => export const uint8ArrayToString = (value: Uint8Array): string => new TextDecoder().decode(value); -/** - * Convert a hex string to an array of 8-bit integers - * @param hex A hex string to convert - * @returns An array of 8-bit integers - */ -export const hexStringToArray = (hex: string) => { - const result = new Uint8Array(hex.length >> 1); - for (let k = 0; k < result.length; k++) { - const i = k << 1; - result[k] = parseInt(hex.substring(i, i + 2), 16); - } - return result; -}; - -const hexAlphabet = "0123456789abcdef"; -/** - * Convert an array of 8-bit integers to a hex string - * @param bytes Array of 8-bit integers to convert - * @returns Hexadecimal representation of the array - */ -export const arrayToHexString = (bytes: Uint8Array) => - bytes.reduce( - (str, byte) => (str += hexAlphabet[byte >> 4] + hexAlphabet[byte & 15]), - "" - ); - -export const isStringHex = (val: string) => /[0-9A-Fa-f]{6}/g.test(val); - export function mergeUint8Arrays(...arrays: Uint8Array[]) { const merged = new Uint8Array( arrays.reduce((sum, arr) => sum + arr.length, 0) @@ -42,3 +14,60 @@ export function mergeUint8Arrays(...arrays: Uint8Array[]) { }, 0); return merged; } + +export const encodeUtf8 = (input: string) => + unescape(encodeURIComponent(input)); +export const decodeUtf8 = (input: string) => decodeURIComponent(escape(input)); +export const encodeBase64 = (input: string) => btoa(input).trim(); +export const decodeBase64 = (input: string) => atob(input.trim()); +export const encodeUtf8Base64 = (input: string) => + encodeBase64(encodeUtf8(input)); +export const decodeUtf8Base64 = (input: string) => + decodeUtf8(decodeBase64(input)); + +export const uint8ArrayToBase64String = (arr: Uint8Array): string => + Buffer.from(arr).toString("base64"); + +export const base64StringToUint8Array = (str: string): Uint8Array => + new Uint8Array(Buffer.from(str, "base64")); + +/** + * Encode a binary string in the so-called base64 URL (https://tools.ietf.org/html/rfc4648#section-5) + * @dev Each character in a binary string can only be one of the characters in a reduced 255 ASCII alphabet. I.e. morally each character is one byte + * @dev This function will fail if the argument contains characters which are not in this alphabet + * @dev This encoding works by converting groups of three "bytes" into groups of four base64 characters (2 ** 6 ** 4 is also three bytes) + * @dev Therefore, if the argument string has a length not divisible by three, the returned string will be padded with one or two '=' characters + */ +export const encodeBase64URL = (str: string, removePadding = true) => { + const base64String = encodeBase64(str) + .replace(/\+/g, "-") + .replace(/\//g, "_"); + + return removePadding ? base64String.replace(/=/g, "") : base64String; +}; + +/** + * Convert a string encoded in base64 URL into a binary string + * @param str + */ +export const decodeBase64URL = (str: string) => { + return decodeBase64( + (str + "===".slice((str.length + 3) % 4)) + .replace(/-/g, "+") + .replace(/_/g, "/") + ); +}; + +export const uint8ArrayToPaddedBase64URLString = (array: Uint8Array) => + encodeBase64URL(uint8ArrayToString(array), false); + +export const validateBase64string = ( + str: string, + useVariantAlphabet?: boolean +) => { + const regex = useVariantAlphabet + ? /^[-_A-Za-z0-9]*={0,3}$/ + : /^[+/A-Za-z0-9]*={0,3}$/; + + return regex.test(str); +}; 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..7d80f15 --- /dev/null +++ b/lib/file/fileChunkreader.ts @@ -0,0 +1,165 @@ +import { CryptoService } from "../crypto"; +import { mbToBytes } from "../fileSizeUtils"; + +export const FOLDER_PAGE_SIZE = 150; +export const BATCH_REQUEST_SIZE = 50; +export const FILE_CHUNK_SIZE = mbToBytes(5); + +type ReadAs = "binary" | "text" | "data-url"; + +// export const MEMORY_DOWNLOAD_LIMIT = (isMobile() ? 100 : 500) * MB; +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 = e.target.result as string; + this.offset += this.chunkSize; + resolve(result); + }; + + this.readAs(readAs, fileReader, blob); + }); + } + + async readInChunks(readAs: ReadAs, fn: (chunk: string) => 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)) { + const next = await this.next(readAs); + this.buffer += atob(next); + if (!next) { + 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, chunks: string[], separator: string) => { + const anchor = document.createElement("a"); + anchor.href = chunks.join(separator); + 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, + chunks: string[], + 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," + btoa(chunks.join(separator)); + 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 cryptoSvc = new CryptoService(); + const hashes = await Promise.all( + contentChunks.map((content) => cryptoSvc.sha256(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.readAsBinaryString(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 185e6ab..fa83bcd 100644 --- a/lib/open-e2ee.ts +++ b/lib/open-e2ee.ts @@ -7,8 +7,10 @@ import { ReceiveItemOut, DecryptItemOut, EncryptItemOut, + pgpMessageEnd, } from "./models"; import { PGPPrivateKey, PGPPublicKey, PGPService } from "./pgp"; +import { FileEncryption } from "./file/fileChunkreader"; export class OpenE2EE { private pgpService: PGPService; @@ -211,4 +213,105 @@ export class OpenE2EE { encryptedMessage: string ): Promise => await this.decrypt(encryptedMessage, [senderPublicKey]); + + encryptFileOnOneGo = async (file: File) => { + const key = await this.pgpService.generateShareKey( + this.publicKey as PGPPublicKey + ); + + const reader = new FileReader(); + reader.onload = async (e) => { + const data = e.target?.result as string; + const encrypted = await this.cryptoService.encryptFile(key, data); + const anchor = document.createElement("a"); + anchor.href = "data:application/octet-stream," + btoa(encrypted); + anchor.download = file.name + ".enc"; + anchor.click(); + }; + reader.readAsDataURL(file); + + const encryptedKey = await this.pgpService.encryptAsymmetric( + this.privateKey, + [this.publicKey as PGPPublicKey], + key + ); + + return { encryptedKey }; + }; + + decryptFileOnOneGo = async (encryptedKey: string, encryptedFile: File) => { + const key = await this.pgpService.decryptAsymmetric( + this.privateKey as PGPPrivateKey, + [this.publicKey as PGPPublicKey], + encryptedKey + ); + + const reader = new FileReader(); + + reader.onload = async (e) => { + const file = e.target?.result as string; + const decoded = atob(file); + const decrypted = await this.cryptoService.decryptFile(key, decoded); + const anchor = document.createElement("a"); + anchor.href = decrypted; + anchor.download = "dec." + encryptedFile.name.replace(".enc", ""); + anchor.click(); + }; + + reader.readAsText(encryptedFile); + + return {}; + }; + + private encryptedChunkSeparator = "CHUNK_END"; + encryptFile = async (file: File) => { + const key = await this.pgpService.generateShareKey( + this.publicKey as PGPPublicKey + ); + const encryptedKey = await this.pgpService.encryptAsymmetric( + this.privateKey, + [this.publicKey as PGPPublicKey], + key + ); + + const encryptedChunks: string[] = []; + + const fileEncryptor = new FileEncryption(file); + await fileEncryptor.readInChunks("data-url", async (chunk: string) => { + const encChunk = await this.cryptoService.encryptFile(key, chunk); + encryptedChunks.push(encChunk); + }); + await fileEncryptor.saveEncryptedChunkedFile( + file.name + ".enc", + encryptedChunks, + this.encryptedChunkSeparator + ); + + return { encryptedKey }; + }; + + decryptFile = async (encryptedKey: string, encryptedFile: File) => { + const key = await this.pgpService.decryptAsymmetric( + this.privateKey as PGPPrivateKey, + [this.publicKey as PGPPublicKey], + encryptedKey + ); + + const decryptedChunks: string[] = []; + + const fileEncryptor = new FileEncryption(encryptedFile); + await fileEncryptor.readEncryptedInChunks( + "text", + this.encryptedChunkSeparator, + async (encChunk: string) => { + const chunk = await this.cryptoService.decryptFile(key, encChunk); + decryptedChunks.push(chunk); + } + ); + await fileEncryptor.saveChunkedFile( + "dec." + encryptedFile.name.replace(".enc", ""), + decryptedChunks, + "" + ); + }; } diff --git a/lib/pgp.ts b/lib/pgp.ts index f60b1c5..e279045 100644 --- a/lib/pgp.ts +++ b/lib/pgp.ts @@ -1,8 +1,8 @@ import * as openpgp from "openpgp"; import { tryCatch } from "./error"; -import { arrayToHexString } from "./encoding.utils"; +import { uint8ArrayToBase64String } from "./encoding.utils"; -openpgp.config.preferredSymmetricAlgorithm = 9; // set default to aes256 +openpgp.config.preferredSymmetricAlgorithm = openpgp.enums.symmetric.aes256; // set default to AES256 export class PGPPrivateKey extends openpgp.PrivateKey {} export class PGPPublicKey extends openpgp.PublicKey {} @@ -26,7 +26,7 @@ export class PGPService { const shareKey = await openpgp.generateSessionKey({ encryptionKeys: publicKey, }); - return arrayToHexString(shareKey.data); + return uint8ArrayToBase64String(shareKey.data); } ); @@ -92,11 +92,20 @@ export class PGPService { encrypt = tryCatch( "pgp.encrypt", - async (key: string, data: string): Promise => { + async ( + key: string, + data: string, + config: EncryptConfig = { compression: false } + ): Promise => { const message = await openpgp.createMessage({ text: data }); const encrypted = await openpgp.encrypt({ message, passwords: key, + config: { + preferredCompressionAlgorithm: config.compression + ? openpgp.enums.compression.zlib + : openpgp.enums.compression.uncompressed, + }, }); return encrypted as string; } @@ -111,3 +120,7 @@ export class PGPService { } ); } + +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", From 70cddfb426e13b040b2160c52938f823fdfd2b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20C=C3=A1nepa?= <8711973+bruncanepa@users.noreply.github.com> Date: Mon, 4 Dec 2023 17:58:30 -0300 Subject: [PATCH 2/3] read as data-url --- lib/crypto.ts | 26 +++++++++++++++-- lib/file/fileChunkreader.ts | 47 +++++++++++++++++++++--------- lib/open-e2ee.ts | 58 ++++--------------------------------- lib/pgp.ts | 5 ++++ 4 files changed, 67 insertions(+), 69 deletions(-) diff --git a/lib/crypto.ts b/lib/crypto.ts index 4a84985..ce0427f 100644 --- a/lib/crypto.ts +++ b/lib/crypto.ts @@ -12,6 +12,7 @@ const { crypto } = globalThis; export class CryptoService { private aesIVLength = 12; + private aesKeyLength = 32; private encryptionAlgorithm = "AES-GCM"; private importSymmetricKey = tryCatch( @@ -26,8 +27,29 @@ export class CryptoService { ) ); - private createRandomValue = (len: number): Uint8Array => - crypto.getRandomValues(new Uint8Array(len)); + /** + * Retrieve secure random byte array of the specified length + * @param {Integer} len length in bytes to generate + * @returns {Uint8Array} random byte array. + */ + private createRandomValue = (len: number): Uint8Array => { + const buf = new Uint8Array(len); + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + return crypto.getRandomValues(buf); + } else { + const nodeCrypto = require("crypto"); + if (nodeCrypto) { + const bytes = nodeCrypto.randomBytes(buf.length); + buf.set(bytes); + } else { + throw new Error("No secure random number generator available."); + } + } + return buf; + }; + + generateSymmetricKey = (): string => + uint8ArrayToBase64String(this.createRandomValue(this.aesKeyLength)); encrypt = tryCatch( "crypto.encrypt", diff --git a/lib/file/fileChunkreader.ts b/lib/file/fileChunkreader.ts index 7d80f15..e5e4a8f 100644 --- a/lib/file/fileChunkreader.ts +++ b/lib/file/fileChunkreader.ts @@ -1,11 +1,11 @@ import { CryptoService } from "../crypto"; -import { mbToBytes } from "../fileSizeUtils"; +import { kbToBytes, mbToBytes } from "../fileSizeUtils"; export const FOLDER_PAGE_SIZE = 150; export const BATCH_REQUEST_SIZE = 50; -export const FILE_CHUNK_SIZE = mbToBytes(5); +export const FILE_CHUNK_SIZE = mbToBytes(5); // kbToBytes(1); //; -type ReadAs = "binary" | "text" | "data-url"; +type ReadAs = "text" | "data-url"; // export const MEMORY_DOWNLOAD_LIMIT = (isMobile() ? 100 : 500) * MB; export class FileEncryption { @@ -33,12 +33,18 @@ export class FileEncryption { e.target?.error || new Error("Cannot open file for reading") ); } - const result = e.target.result as string; 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); }); } @@ -52,11 +58,18 @@ export class FileEncryption { private buffer = ""; - async nextEncrypted(readAs: ReadAs, separator: string): Promise { + async nextEncrypted( + readAs: ReadAs, + separator: string, + firstChunk: boolean + ): Promise { while (!this.buffer.includes(separator)) { - const next = await this.next(readAs); - this.buffer += atob(next); - if (!next) { + 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; + if (!next.length) { break; } } @@ -77,15 +90,24 @@ export class FileEncryption { separator: string, fn: (encryptedChunk: string) => any ) { + let firstChunk = true; while (!this.isEOF()) { - const chunk = await this.nextEncrypted(readAs, separator); + const chunk = await this.nextEncrypted(readAs, separator, firstChunk); + firstChunk = false; await fn(chunk); } } saveChunkedFile = (name: string, chunks: string[], separator: string) => { const anchor = document.createElement("a"); - anchor.href = chunks.join(separator); + anchor.href = + "data:application/octet-stream;base64," + + btoa( + chunks + .map((c) => c.replace("data:application/octet-stream;base64,", "")) + .map((c) => atob(c)) + .join("") + ); anchor.download = name; anchor.click(); @@ -115,8 +137,7 @@ export class FileEncryption { const anchor = document.createElement("a"); anchor.download = name; anchor.target = "_blank"; - anchor.href = - "data:application/octet-stream," + btoa(chunks.join(separator)); + anchor.href = "data:application/octet-stream," + chunks.join(separator); anchor.click(); URL.revokeObjectURL(anchor.href); @@ -158,8 +179,6 @@ export class FileEncryption { case "data-url": fileReader.readAsDataURL(blob); break; - default: - fileReader.readAsBinaryString(blob); } } } diff --git a/lib/open-e2ee.ts b/lib/open-e2ee.ts index fa83bcd..1367155 100644 --- a/lib/open-e2ee.ts +++ b/lib/open-e2ee.ts @@ -104,7 +104,7 @@ export class OpenE2EE { [this.publicKey as PGPPublicKey], shareKey ), - this.cryptoService.encrypt(shareKey, data), + this.pgpService.encrypt(shareKey, data), ]); return { shareKey, @@ -115,6 +115,7 @@ export class OpenE2EE { /** * Decrypts the key using PGP and the item with the decrypted key. * @param encryptedMessage encrypted message that contains both key and data + * @param externalEncryptionKeys external PGP public keys to decrypt the share key (for sharing) * @returns both key and data decrypted */ decrypt = async ( @@ -131,7 +132,7 @@ export class OpenE2EE { [this.publicKey as PGPPublicKey, ...externalEncryptionKeysObj], encryptedShareKey ); - const data = await this.cryptoService.decrypt(shareKey, encryptedData); + const data = await this.pgpService.decrypt(shareKey, encryptedData); return { shareKey, data }; }; @@ -214,56 +215,7 @@ export class OpenE2EE { ): Promise => await this.decrypt(encryptedMessage, [senderPublicKey]); - encryptFileOnOneGo = async (file: File) => { - const key = await this.pgpService.generateShareKey( - this.publicKey as PGPPublicKey - ); - - const reader = new FileReader(); - reader.onload = async (e) => { - const data = e.target?.result as string; - const encrypted = await this.cryptoService.encryptFile(key, data); - const anchor = document.createElement("a"); - anchor.href = "data:application/octet-stream," + btoa(encrypted); - anchor.download = file.name + ".enc"; - anchor.click(); - }; - reader.readAsDataURL(file); - - const encryptedKey = await this.pgpService.encryptAsymmetric( - this.privateKey, - [this.publicKey as PGPPublicKey], - key - ); - - return { encryptedKey }; - }; - - decryptFileOnOneGo = async (encryptedKey: string, encryptedFile: File) => { - const key = await this.pgpService.decryptAsymmetric( - this.privateKey as PGPPrivateKey, - [this.publicKey as PGPPublicKey], - encryptedKey - ); - - const reader = new FileReader(); - - reader.onload = async (e) => { - const file = e.target?.result as string; - const decoded = atob(file); - const decrypted = await this.cryptoService.decryptFile(key, decoded); - const anchor = document.createElement("a"); - anchor.href = decrypted; - anchor.download = "dec." + encryptedFile.name.replace(".enc", ""); - anchor.click(); - }; - - reader.readAsText(encryptedFile); - - return {}; - }; - - private encryptedChunkSeparator = "CHUNK_END"; + private encryptedChunkSeparator = "_ENDCHUNK_"; encryptFile = async (file: File) => { const key = await this.pgpService.generateShareKey( this.publicKey as PGPPublicKey @@ -301,7 +253,7 @@ export class OpenE2EE { const fileEncryptor = new FileEncryption(encryptedFile); await fileEncryptor.readEncryptedInChunks( - "text", + "data-url", this.encryptedChunkSeparator, async (encChunk: string) => { const chunk = await this.cryptoService.decryptFile(key, encChunk); diff --git a/lib/pgp.ts b/lib/pgp.ts index e279045..422f01c 100644 --- a/lib/pgp.ts +++ b/lib/pgp.ts @@ -119,6 +119,11 @@ export class PGPService { return decrypted.data as string; } ); + + encryptFile = (key: string, data: string) => + this.encrypt(key, data, { compression: true }); + + decryptFile = (key: string, data: string) => this.decrypt(key, data); } export interface EncryptConfig { From 85b55f3306be3574c2314bdab28655d48f61f324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20C=C3=A1nepa?= <8711973+bruncanepa@users.noreply.github.com> Date: Wed, 6 Dec 2023 12:19:22 -0300 Subject: [PATCH 3/3] added features --- examples/with-reactjs.tsx | 93 +++++++++++++- lib/compression.ts | 4 +- lib/crypto.ts | 76 +++++++---- lib/error.ts | 20 +-- lib/file/fileChunkreader.ts | 60 +++++---- lib/open-e2ee.ts | 247 +++++++++++++++++++++++------------- lib/pgp.ts | 84 ++++++++---- 7 files changed, 395 insertions(+), 189 deletions(-) diff --git a/examples/with-reactjs.tsx b/examples/with-reactjs.tsx index 73459d6..4a2ee8b 100644 --- a/examples/with-reactjs.tsx +++ b/examples/with-reactjs.tsx @@ -1,11 +1,37 @@ "use client"; -import React, { useState, useMemo, useEffect } from "react"; -import { OpenE2EE } from "../../../lib/open-e2ee"; +import React, { useState, useMemo, useEffect, ChangeEvent } from "react"; +import { OpenE2EE } from "../lib/open-e2ee"; const userID = "2997e638-b01b-446f-be33-df9ec8b4f206"; -export function OpenE2EEExample() { + +export default function Examples() { + const [page, setPage] = useState<"text" | "files">("files"); + return ( +
+ +
+
+ {page === "text" && } + {page === "files" && } +
+ ); +} + +function TextExample() { const [passphrase, setPassphrase] = useState("passphrase-long-super-long"); - const etoeeSvc = useMemo(() => new OpenE2EE(userID, passphrase), []); + const etoeeSvc = useMemo( + () => new OpenE2EE(userID, passphrase, ["share"]), + [] + ); const [data, setData] = useState("data super secret to encrypt"); const [encrypted, setEncrypted] = useState(""); @@ -34,7 +60,7 @@ export function OpenE2EEExample() { }; const onLoadPGPPrivateKey = async () => { - const svcLoaded = await new OpenE2EE(userID, passphrase).load( + const svcLoaded = await new OpenE2EE(userID, passphrase, ["share"]).load( privateKey, publicKey ); @@ -46,7 +72,9 @@ export function OpenE2EEExample() { }; const onShare = async () => { - const receiverSvc = await new OpenE2EE(userID + 1, passphrase + 1).build(); + const receiverSvc = await new OpenE2EE(userID + 1, passphrase + 1, [ + "share", + ]).build(); const { publicKey: receiverPublicKey } = await receiverSvc.exportMasterKeys(); @@ -131,3 +159,56 @@ export function OpenE2EEExample() { ); } + +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 index 069fc73..c19f0af 100644 --- a/lib/compression.ts +++ b/lib/compression.ts @@ -1,6 +1,8 @@ import pako from "pako"; -export const compress = (data: string): Uint8Array => pako.deflate(data); +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 ce0427f..1902f4d 100644 --- a/lib/crypto.ts +++ b/lib/crypto.ts @@ -1,4 +1,4 @@ -import { compress, decompress } from "./compression"; +import { compress, compressString, decompress } from "./compression"; import { base64StringToUint8Array, uint8ArrayToBase64String, @@ -10,12 +10,12 @@ import { tryCatch } from "./error"; const { crypto } = globalThis; -export class CryptoService { - private aesIVLength = 12; - private aesKeyLength = 32; - private encryptionAlgorithm = "AES-GCM"; +export class Crypto { + private static aesIVLength = 12; + private static aesKeyLength = 32; + private static encryptionAlgorithm = "AES-GCM"; - private importSymmetricKey = tryCatch( + private static importSymmetricKey = tryCatch( "crypto.importSymmetricKey", (key: string): Promise => crypto.subtle.importKey( @@ -32,7 +32,7 @@ export class CryptoService { * @param {Integer} len length in bytes to generate * @returns {Uint8Array} random byte array. */ - private createRandomValue = (len: number): Uint8Array => { + private static createRandomValue = (len: number): Uint8Array => { const buf = new Uint8Array(len); if (typeof crypto !== "undefined" && crypto.getRandomValues) { return crypto.getRandomValues(buf); @@ -48,10 +48,10 @@ export class CryptoService { return buf; }; - generateSymmetricKey = (): string => + static generateSymmetricKey = (): string => uint8ArrayToBase64String(this.createRandomValue(this.aesKeyLength)); - encrypt = tryCatch( + static encrypt = tryCatch( "crypto.encrypt", async ( key: string, @@ -62,7 +62,7 @@ export class CryptoService { const keyObj = await this.importSymmetricKey(key); const dataBuf = config.compression - ? compress(data) + ? compressString(data) : stringToUint8Array(data); const encryptedData = await crypto.subtle.encrypt( @@ -76,7 +76,7 @@ export class CryptoService { } ); - decrypt = tryCatch( + static decrypt = tryCatch( "crypto.decrypt", async ( key: string, @@ -101,26 +101,50 @@ export class CryptoService { } ); - encryptFile = (key: string, data: string) => - this.encrypt(key, data, { compression: true }); + static encryptFile = tryCatch( + "crypto.encryptFile", + async (key: string, data: Uint8Array): Promise => { + const iv = this.createRandomValue(this.aesIVLength); + const keyObj = await this.importSymmetricKey(key); - decryptFile = (key: string, data: string) => - this.decrypt(key, data, { compression: true }); + const dataBuf = compress(data); - /** - * Hash with sha256 a message - * @param message data to hash - * @returns sha256 hash base64 encoded - */ - sha256 = async (message: string) => { + 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 ""; } - const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array - const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8); // hash the message - const hashBase64 = uint8ArrayToBase64String(new Uint8Array(hashBuffer)); // convert buffer to base64 - return hashBase64; - }; + const msgUint8 = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8); + return uint8ArrayToBase64String(new Uint8Array(hashBuffer)); + }); } interface EncryptConfig { diff --git a/lib/error.ts b/lib/error.ts index 2c1004f..0abf738 100644 --- a/lib/error.ts +++ b/lib/error.ts @@ -1,12 +1,14 @@ -export class OpenE2EEError extends Error { - constructor(method: string, error: Error) { - const message = `${method}: ${error.message}`; - super(message); - this.message = message; - this.name = error.name; - this.stack = error.stack; +const wrapError = (message: string, error: Error): Error => { + if (!error) { + return new Error(message); } -} + + try { + error.message = `${message}: ${error.message}`; + } catch (e) {} + + return error; +}; /** * Generic function that accepts any number of parameters. @@ -35,7 +37,7 @@ export function tryCatch( try { return await func(...args); } catch (error) { - throw new OpenE2EEError(method, error as Error); + throw wrapError(method, error as Error); } }; } diff --git a/lib/file/fileChunkreader.ts b/lib/file/fileChunkreader.ts index e5e4a8f..2c4e016 100644 --- a/lib/file/fileChunkreader.ts +++ b/lib/file/fileChunkreader.ts @@ -1,13 +1,13 @@ -import { CryptoService } from "../crypto"; +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 = "text" | "data-url"; +type ReadAs = "buffer" | "text" | "data-url"; -// export const MEMORY_DOWNLOAD_LIMIT = (isMobile() ? 100 : 500) * MB; export class FileEncryption { private blob: Blob; private chunkSize: number; @@ -26,14 +26,14 @@ export class FileEncryption { const fileReader = new FileReader(); const blob = this.blob.slice(this.offset, this.offset + this.chunkSize); - return new Promise((resolve, reject) => { + 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 = e.target.result as string; + const result = new Uint8Array(e.target.result as ArrayBuffer); this.offset += this.chunkSize; resolve(result); }; @@ -49,7 +49,7 @@ export class FileEncryption { }); } - async readInChunks(readAs: ReadAs, fn: (chunk: string) => any) { + async readInChunks(readAs: ReadAs, fn: (chunk: Uint8Array) => any) { while (!this.isEOF()) { const chunk = await this.next(readAs); await fn(chunk); @@ -58,17 +58,14 @@ export class FileEncryption { private buffer = ""; - async nextEncrypted( - readAs: ReadAs, - separator: string, - firstChunk: boolean - ): Promise { + 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; + // 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; } @@ -90,24 +87,23 @@ export class FileEncryption { separator: string, fn: (encryptedChunk: string) => any ) { - let firstChunk = true; while (!this.isEOF()) { - const chunk = await this.nextEncrypted(readAs, separator, firstChunk); - firstChunk = false; + const chunk = await this.nextEncrypted(readAs, separator); await fn(chunk); } } - saveChunkedFile = (name: string, chunks: string[], separator: string) => { + 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 = + // "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(); @@ -127,7 +123,7 @@ export class FileEncryption { saveEncryptedChunkedFile = async ( name: string, - chunks: string[], + encryptedBlob: Blob, separator: string ) => { // const blob = await fetch( @@ -137,7 +133,8 @@ export class FileEncryption { const anchor = document.createElement("a"); anchor.download = name; anchor.target = "_blank"; - anchor.href = "data:application/octet-stream," + chunks.join(separator); + // anchor.href = "data:application/octet-stream," + encryptedBlob.join(separator); + anchor.href = URL.createObjectURL(encryptedBlob); anchor.click(); URL.revokeObjectURL(anchor.href); @@ -164,9 +161,8 @@ export class FileEncryption { }; computeContentHash = async (contentChunks: string[]) => { - const cryptoSvc = new CryptoService(); const hashes = await Promise.all( - contentChunks.map((content) => cryptoSvc.sha256(content)) + contentChunks.map((content) => Crypto.digest(content)) ); return hashes.join("_"); }; @@ -179,6 +175,8 @@ export class FileEncryption { case "data-url": fileReader.readAsDataURL(blob); break; + default: + fileReader.readAsArrayBuffer(blob); } } } diff --git a/lib/open-e2ee.ts b/lib/open-e2ee.ts index 1367155..bad91fa 100644 --- a/lib/open-e2ee.ts +++ b/lib/open-e2ee.ts @@ -1,4 +1,3 @@ -import { CryptoService } from "./crypto"; import { writeEncryptedItem, readEncryptedItem, @@ -7,31 +6,36 @@ import { ReceiveItemOut, DecryptItemOut, EncryptItemOut, - pgpMessageEnd, } from "./models"; -import { PGPPrivateKey, PGPPublicKey, PGPService } from "./pgp"; +import { PGPPrivateKey, PGPPublicKey, PGP } from "./pgp"; import { FileEncryption } from "./file/fileChunkreader"; +import { + base64StringToUint8Array, + stringToUint8Array, + uint8ArrayToBase64String, +} from "./encoding.utils"; export class OpenE2EE { - private pgpService: PGPService; - private cryptoService: CryptoService; - private passphrase: string; - private privateKey?: PGPPrivateKey; + private privateKey: PGPPrivateKey; private privateKeyEncryptedText: string = ""; - private publicKey?: PGPPublicKey; + private publicKey: PGPPublicKey; private publicKeyText: string = ""; private userId: string; + private features: Feature[]; /** * @param userId user id in your platform * @param passphrase master password to encrypt PGP private key + * @param features features that wants to be used: + * - share: to encrypt data prepared to be shared in the future with another user. + * Note that if this is not enabled from start, to share you will need to re-encrypt the data. + * - files: to encrypt/decrypt files. */ - constructor(userId: string, passphrase: string) { - this.pgpService = new PGPService(); - this.cryptoService = new CryptoService(); + constructor(userId: string, passphrase: string, features: Feature[] = []) { this.userId = userId; this.passphrase = passphrase; + this.features = features; } /** @@ -39,13 +43,13 @@ export class OpenE2EE { * @example const e2eeSvc = await new E2EEService().build(passphrase); */ build = async (): Promise => { - const { privateKey, publicKey } = await this.pgpService.generateKeyPair( + const { privateKey, publicKey } = await PGP.generateKeyPair( this.passphrase, this.userId ); const keysObj = await Promise.all([ - this.pgpService.decryptPrivateKey(privateKey, this.passphrase), - this.pgpService.readPublicKey(publicKey), + PGP.decryptPrivateKey(privateKey, this.passphrase), + PGP.readPublicKey(publicKey), ]); this.privateKey = keysObj[0]; this.publicKey = keysObj[1]; @@ -65,8 +69,8 @@ export class OpenE2EE { publicKey: string ): Promise => { const [privateKeyObj, publicKeyObj] = await Promise.all([ - this.pgpService.decryptPrivateKey(encryptedPrivateKey, this.passphrase), - this.pgpService.readPublicKey(publicKey), + PGP.decryptPrivateKey(encryptedPrivateKey, this.passphrase), + PGP.readPublicKey(publicKey), ]); this.privateKey = privateKeyObj; this.publicKey = publicKeyObj; @@ -91,49 +95,57 @@ export class OpenE2EE { * @param data value to encrypt * @returns encrypted message with key and data. */ - encrypt = async ( - data: string, - sign: boolean = true - ): Promise => { - const shareKey = await this.pgpService.generateShareKey( - this.publicKey as PGPPublicKey + encrypt = async (data: string): Promise => { + if (this.getFeature("share")) { + const { shareKey, encryptedShareKey } = await PGP.generateShareKey( + this.publicKey + ); + const encryptedData = await PGP.encrypt(this.privateKey, shareKey, data); + return { + shareKey, + encryptedMessage: writeEncryptedItem(encryptedShareKey, encryptedData), + }; + } + const encryptedMessage = await PGP.encryptAsymmetric( + this.privateKey, + [this.publicKey], + data ); - const [encryptedShareKey, encryptedData] = await Promise.all([ - this.pgpService.encryptAsymmetric( - sign ? this.privateKey : undefined, - [this.publicKey as PGPPublicKey], - shareKey - ), - this.pgpService.encrypt(shareKey, data), - ]); - return { - shareKey, - encryptedMessage: writeEncryptedItem(encryptedShareKey, encryptedData), - }; + return { encryptedMessage, shareKey: "" }; }; /** * Decrypts the key using PGP and the item with the decrypted key. * @param encryptedMessage encrypted message that contains both key and data - * @param externalEncryptionKeys external PGP public keys to decrypt the share key (for sharing) + * @param externalEncryptionKeys external PGP public keys to decrypt (verify) the share key (for sharing) * @returns both key and data decrypted */ decrypt = async ( encryptedMessage: string, externalEncryptionKeys: string[] = [] ): Promise => { - const { encryptedShareKey, encryptedData } = - readEncryptedItem(encryptedMessage); - const externalEncryptionKeysObj = await Promise.all( - externalEncryptionKeys.map((e) => this.pgpService.readPublicKey(e)) - ); - const shareKey = await this.pgpService.decryptAsymmetric( - this.privateKey as PGPPrivateKey, - [this.publicKey as PGPPublicKey, ...externalEncryptionKeysObj], - encryptedShareKey + const verificationKeys = [ + this.publicKey, + ...(await Promise.all( + externalEncryptionKeys.map((e) => PGP.readPublicKey(e)) + )), + ]; + if (this.getFeature("share")) { + const { encryptedShareKey, encryptedData } = + readEncryptedItem(encryptedMessage); + const shareKey = await PGP.decryptShareKey( + this.privateKey, + encryptedShareKey + ); + const data = await PGP.decrypt(shareKey, encryptedData, verificationKeys); + return { shareKey, data }; + } + const data = await PGP.decryptAsymmetric( + this.privateKey, + verificationKeys, + encryptedMessage ); - const data = await this.pgpService.decrypt(shareKey, encryptedData); - return { shareKey, data }; + return { data, shareKey: "" }; }; /** @@ -147,24 +159,19 @@ export class OpenE2EE { receiverPublicKey: string, encryptedItem: string ): Promise => { + this.needsFeatures("share"); + const { encryptedShareKey, encryptedData } = readEncryptedItem(encryptedItem); - const [receiverPublicKeyObj, shareKey] = await Promise.all([ - this.pgpService.readPublicKey(receiverPublicKey), - this.pgpService.decryptAsymmetric( - this.privateKey as PGPPrivateKey, - [this.publicKey as PGPPublicKey], - encryptedShareKey - ), + PGP.readPublicKey(receiverPublicKey), + PGP.decryptShareKey(this.privateKey, encryptedShareKey), ]); - - const receiverEncryptedKey = await this.pgpService.encryptAsymmetric( - this.privateKey as PGPPrivateKey, - [this.publicKey as PGPPublicKey, receiverPublicKeyObj], + const receiverEncryptedKey = await PGP.encryptAsymmetric( + this.privateKey, + [this.publicKey, receiverPublicKeyObj], shareKey ); - return { senderPublicKey: this.publicKeyText, receiverEncryptedMessage: writeEncryptedItem( @@ -184,12 +191,15 @@ export class OpenE2EE { receiverPublicKey: string, data: string ): Promise => { - const receiverPublicKeyObj = await this.pgpService.readPublicKey( - receiverPublicKey - ); - const { shareKey, encryptedMessage } = await this.encrypt(data); - const receiverEncryptedKey = await this.pgpService.encryptAsymmetric( - this.privateKey as PGPPrivateKey, + this.needsFeatures("share"); + + const [receiverPublicKeyObj, { shareKey, encryptedMessage }] = + await Promise.all([ + PGP.readPublicKey(receiverPublicKey), + this.encrypt(data), + ]); + const receiverEncryptedKey = await PGP.encryptAsymmetric( + this.privateKey, [receiverPublicKeyObj], shareKey ); @@ -206,64 +216,119 @@ export class OpenE2EE { /** * Receive an encrypted message with my PGP public key and signed with sender PGP private key * @param senderPublicKey sender's PGP public key to validate signature - * @param encryptedMessage + * @param encryptedItem the shared item encrypted * @returns decrypted key and data */ receive = async ( senderPublicKey: string, - encryptedMessage: string - ): Promise => - await this.decrypt(encryptedMessage, [senderPublicKey]); + encryptedItem: string + ): Promise => { + this.needsFeatures("share"); + + const { encryptedShareKey, encryptedData } = + readEncryptedItem(encryptedItem); + const senderPublicKeyObj = await PGP.readPublicKey(senderPublicKey); + const shareKey = await PGP.decryptAsymmetric( + this.privateKey, + [this.publicKey, senderPublicKeyObj], + encryptedShareKey + ); + const data = await PGP.decrypt(shareKey, encryptedData, [ + senderPublicKeyObj, + ]); + return { shareKey, data }; + }; private encryptedChunkSeparator = "_ENDCHUNK_"; encryptFile = async (file: File) => { - const key = await this.pgpService.generateShareKey( - this.publicKey as PGPPublicKey - ); - const encryptedKey = await this.pgpService.encryptAsymmetric( - this.privateKey, - [this.publicKey as PGPPublicKey], - key + this.needsFeatures("files"); + + const { shareKey, encryptedShareKey } = await PGP.generateShareKey( + this.publicKey ); - const encryptedChunks: string[] = []; + let encryptedChunks: Uint8Array[] = []; const fileEncryptor = new FileEncryption(file); - await fileEncryptor.readInChunks("data-url", async (chunk: string) => { - const encChunk = await this.cryptoService.encryptFile(key, chunk); - encryptedChunks.push(encChunk); + 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", - encryptedChunks, + new Blob(encryptedChunks, { type: file.type }), this.encryptedChunkSeparator ); - return { encryptedKey }; + return { encryptedKey: encryptedShareKey }; }; decryptFile = async (encryptedKey: string, encryptedFile: File) => { - const key = await this.pgpService.decryptAsymmetric( - this.privateKey as PGPPrivateKey, - [this.publicKey as PGPPublicKey], - encryptedKey - ); + this.needsFeatures("files"); + + const shareKey = await PGP.decryptShareKey(this.privateKey, encryptedKey); - const decryptedChunks: string[] = []; + const decryptedChunks: Uint8Array[] = []; const fileEncryptor = new FileEncryption(encryptedFile); await fileEncryptor.readEncryptedInChunks( - "data-url", + "buffer", this.encryptedChunkSeparator, async (encChunk: string) => { - const chunk = await this.cryptoService.decryptFile(key, encChunk); - decryptedChunks.push(chunk); + const chunk = await PGP.decryptFile(shareKey, encChunk); + decryptedChunks.push(stringToUint8Array(chunk)); } ); await fileEncryptor.saveChunkedFile( "dec." + encryptedFile.name.replace(".enc", ""), - decryptedChunks, + new Blob(decryptedChunks, { type: encryptedFile.type }), "" ); }; + + private needsFeatures = (neededFeatures: Feature[] | Feature) => { + if (typeof neededFeatures === "string") { + neededFeatures = [neededFeatures]; + } + const achieved = neededFeatures.every((feature) => + this.features.find((ft) => ft === feature) + ); + if (!achieved) { + throw Error(`${neededFeatures.join(",")} features need to be enabled`); + } + }; + 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; +} + +type Feature = "share" | "files"; diff --git a/lib/pgp.ts b/lib/pgp.ts index 422f01c..d8a4bb3 100644 --- a/lib/pgp.ts +++ b/lib/pgp.ts @@ -4,11 +4,11 @@ import { uint8ArrayToBase64String } from "./encoding.utils"; openpgp.config.preferredSymmetricAlgorithm = openpgp.enums.symmetric.aes256; // set default to AES256 -export class PGPPrivateKey extends openpgp.PrivateKey {} -export class PGPPublicKey extends openpgp.PublicKey {} +export type PGPPrivateKey = openpgp.PrivateKey | undefined; +export type PGPPublicKey = openpgp.PublicKey | undefined; -export class PGPService { - generateKeyPair = tryCatch( +export class PGP { + static generateKeyPair = tryCatch( "pgp.generateKeyPair", async (passphrase: string, userId: string) => await openpgp.generateKey({ @@ -20,23 +20,46 @@ export class PGPService { }) ); - generateShareKey = tryCatch( + static generateShareKey = tryCatch( "pgp.generateShareKey", - async (publicKey: PGPPublicKey): Promise => { + async (encryptionKeys: PGPPublicKey | PGPPublicKey[]) => { const shareKey = await openpgp.generateSessionKey({ - encryptionKeys: publicKey, + encryptionKeys: encryptionKeys as openpgp.PublicKey[], + }); + const encryptedShareKey = await openpgp.encryptSessionKey({ + ...shareKey, + encryptionKeys: encryptionKeys as openpgp.PublicKey[], + }); + return { + shareKey: uint8ArrayToBase64String(shareKey.data), + encryptedShareKey, + }; + } + ); + + static decryptShareKey = tryCatch( + "pgp.decryptShareKey", + async ( + decryptionKey: PGPPrivateKey, + encryptedShareKey: string + ): Promise => { + const [shareKey] = await openpgp.decryptSessionKeys({ + message: await openpgp.readMessage({ + armoredMessage: encryptedShareKey, + }), + decryptionKeys: decryptionKey, }); return uint8ArrayToBase64String(shareKey.data); } ); - readPublicKey = tryCatch( + static readPublicKey = tryCatch( "pgp.readPublicKey", - async (publicKeyArmored: string) => + async (publicKeyArmored: string): Promise => (await openpgp.readKey({ armoredKey: publicKeyArmored })) as PGPPublicKey ); - readPrivateKey = tryCatch( + static readPrivateKey = tryCatch( "pgp.readPrivateKey", async (privateKeyArmored: string) => (await openpgp.readPrivateKey({ @@ -44,35 +67,35 @@ export class PGPService { })) as PGPPrivateKey ); - decryptPrivateKey = tryCatch( + static decryptPrivateKey = tryCatch( "pgp.decryptPrivateKey", async (privateKeyArmored: string, passphrase: string) => { const privateKey = await this.readPrivateKey(privateKeyArmored); return (await openpgp.decryptKey({ - privateKey, + privateKey: privateKey as openpgp.PrivateKey, passphrase, })) as PGPPrivateKey; } ); - encryptAsymmetric = tryCatch( + static encryptAsymmetric = tryCatch( "pgp.encryptAsymmetric", async ( - privateKey: PGPPrivateKey | undefined, + signingKey: PGPPrivateKey, encryptionKeys: PGPPublicKey[], data: string ): Promise => { const message = await openpgp.createMessage({ text: data }); const encrypted = await openpgp.encrypt({ message, - encryptionKeys, - signingKeys: privateKey, + encryptionKeys: encryptionKeys as openpgp.PublicKey[], + signingKeys: signingKey, }); return encrypted as string; } ); - decryptAsymmetric = tryCatch( + static decryptAsymmetric = tryCatch( "pgp.decryptAsymmetric", async ( privateKey: PGPPrivateKey, @@ -82,7 +105,7 @@ export class PGPService { const message = await openpgp.readMessage({ armoredMessage: data }); const decrypted = await openpgp.decrypt({ message, - verificationKeys: verificationKeys, + verificationKeys: verificationKeys as openpgp.PublicKey[], decryptionKeys: privateKey, expectSigned: true, }); @@ -90,9 +113,10 @@ export class PGPService { } ); - encrypt = tryCatch( + static encrypt = tryCatch( "pgp.encrypt", async ( + signingKey: PGPPrivateKey, key: string, data: string, config: EncryptConfig = { compression: false } @@ -101,6 +125,7 @@ export class PGPService { const encrypted = await openpgp.encrypt({ message, passwords: key, + signingKeys: signingKey, config: { preferredCompressionAlgorithm: config.compression ? openpgp.enums.compression.zlib @@ -111,19 +136,28 @@ export class PGPService { } ); - decrypt = tryCatch( + static decrypt = tryCatch( "pgp.decrypt", - async (key: string, data: string): Promise => { + async ( + key: string, + data: string, + verificationKeys?: PGPPublicKey[] + ): Promise => { const message = await openpgp.readMessage({ armoredMessage: data }); - const decrypted = await openpgp.decrypt({ message, passwords: key }); + const decrypted = await openpgp.decrypt({ + message, + passwords: key, + verificationKeys: verificationKeys as openpgp.PublicKey[], + expectSigned: Boolean(verificationKeys?.length), + }); return decrypted.data as string; } ); - encryptFile = (key: string, data: string) => - this.encrypt(key, data, { compression: true }); + static encryptFile = (key: string, data: string) => + this.encrypt(undefined, key, data, { compression: true }); - decryptFile = (key: string, data: string) => this.decrypt(key, data); + static decryptFile = (key: string, data: string) => this.decrypt(key, data); } export interface EncryptConfig {