Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
57 changes: 56 additions & 1 deletion examples/with-reactjs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<main style={{ width: "80%" }}>
<nav
Expand All @@ -16,10 +16,12 @@ export default function Examples() {
}}
>
<a onClick={() => setPage("text")}>Text</a>
<a onClick={() => setPage("files")}>Files</a>
</nav>
<br />
<br />
{page === "text" && <TextExample />}
{page === "files" && <FilesPage />}
</main>
);
}
Expand Down Expand Up @@ -157,3 +159,56 @@ function TextExample() {
</main>
);
}

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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
const files = (e.target as HTMLInputElement).files;
if (!files?.length) return console.log("no files selected");
await openE2EESvc.decryptFile(encryptKey, files[0]);
};

return (
<div style={{ display: "flex", flexDirection: "column" }}>
<label htmlFor="encrypt">Encrypt file:</label>
<input
type="file"
id="encrypt"
name="encrypt"
// accept="image/png, image/jpeg"
onChange={onEncryptFile}
/>
<br />
<br />
<br />
<label htmlFor="decrypt">Decrypt file:</label>
<input
type="file"
id="decrypt"
name="decrypt"
accept="enc"
onChange={onDecryptFile}
/>
</div>
);
};
8 changes: 8 additions & 0 deletions lib/compression.ts
Original file line number Diff line number Diff line change
@@ -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);
67 changes: 62 additions & 5 deletions lib/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { compress, compressString, decompress } from "./compression";
import {
base64StringToUint8Array,
uint8ArrayToBase64String,
Expand Down Expand Up @@ -47,10 +48,18 @@ export class Crypto {

static encrypt = tryCatch(
"crypto.encrypt",
async (key: string, data: string): Promise<string> => {
async (
key: string,
data: string,
config: EncryptConfig = { compression: false }
): Promise<string> => {
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,
Expand All @@ -64,7 +73,11 @@ export class Crypto {

static decrypt = tryCatch(
"crypto.decrypt",
async (key: string, encryptedData: string): Promise<string> => {
async (
key: string,
encryptedData: string,
config: EncryptConfig = { compression: false }
): Promise<string> => {
const encryptedBuffer = base64StringToUint8Array(encryptedData);
const iv = encryptedBuffer.slice(0, this.aesIVLength);
const cipher = encryptedBuffer.slice(
Expand All @@ -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<Uint8Array> => {
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<Uint8Array> => {
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;
}
143 changes: 143 additions & 0 deletions lib/file/encryption.ts
Original file line number Diff line number Diff line change
@@ -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<EncryptedBlock> {
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<ThumbnailEncryptedBlock> {
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<ThumbnailEncryptedBlock> {
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<EncryptedBlock> {
const tryEncrypt = async (retryCount: number): Promise<EncryptedBlock> => {
// 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);
}
Loading