From 9b9f1110cabb84dacead186f04c6dabca1f4cb28 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 11 Apr 2026 20:32:07 +0530 Subject: [PATCH] refactor: big brain moment - implement envelope encryption for images and metadata with canvas serialization support --- frontend/src/utils/crypto.ts | 195 ++++++++++++++++++++++++++++++----- 1 file changed, 170 insertions(+), 25 deletions(-) diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts index 995de71..8a7814d 100644 --- a/frontend/src/utils/crypto.ts +++ b/frontend/src/utils/crypto.ts @@ -1,22 +1,35 @@ /** - * 0 knowledge cryptography — no server involvement in encryption/decryption. + * 0 knowledge cryptography. No Server involved in encryption/decryption */ -// IV is the Initialization Vector - random value to randomize the encryption output -// DEK is the Data Encryption Key - random value to encrypt the plaintext export interface EncryptedLetter { - encrypted_content: string; // IV + ciphertext, base64 - encrypted_dek: string; // IV + wrapped DEK, base64 - sharingKey: string; // raw DEK, base64 (embedded in share URL) + encrypted_content: string; + encrypted_dek: string; + sharingKey: string; +} + +export interface EncryptedImageUpload { + encryptedBlob: Blob; + encrypted_dek: string; + sharingKey: string; } const PBKDF2_ITERATIONS = 100_000; const AES_GCM = { name: "AES-GCM", length: 256 } as const; +// base64 conversion for transit const toBase64 = (buf: Uint8Array): string => btoa(buf.reduce((s, b) => s + String.fromCharCode(b), "")); -// Prefix the IV to data and base64-encode the result. +// explicit loop ensures Uint8Array (not ArrayBufferLike) +const fromBase64 = (b64: string): Uint8Array => { + const str = atob(b64); + const arr = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) arr[i] = str.charCodeAt(i); + return arr; +}; + +// bundle IV + data into a single base64 string const packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => { const packed = new Uint8Array(iv.length + data.byteLength); packed.set(iv); @@ -24,9 +37,17 @@ const packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => { return toBase64(packed); }; +// split IV (first 12 bytes) back out from a packed base64 bundle +const unpackWithIv = ( + b64: string, +): [Uint8Array, Uint8Array] => { + const buf = fromBase64(b64); // ArrayBuffer-backed, so buf.buffer is ArrayBuffer + return [new Uint8Array(buf.buffer, 0, 12), new Uint8Array(buf.buffer, 12)]; +}; + /** - * Derives a Master Key from the user's password and email (used as PBKDF2 salt). - * Note: it is deterministic, i.e. the same credentials always produce the same key + * Derives a Master Key from a password and email (salt). + * Deterministic — same credentials always produce the same key. */ export async function deriveMasterKey( password: string, @@ -56,34 +77,37 @@ export async function deriveMasterKey( ); } -/** - * Encrypts a letter using Envelope Encryption. - * - * plaintext >> DEK >> encrypted_content - * DEK >> masterKey >> encrypted_dek - * DEK >> raw >> sharingKey +/* + * Wrapper functions */ -export async function encryptLetter( - plaintext: string, - masterKey: CryptoKey, -): Promise { - const enc = new TextEncoder(); +interface SealedEnvelope { + encryptedContent: string; + encrypted_dek: string; + sharingKey: string; +} - // 1time DEK for this letter +async function sealEnvelope( + input: Uint8Array, + masterKey: CryptoKey, +): Promise { + // copy into a fresh ArrayBuffer — WebCrypto requires ArrayBuffer-backed arrays + const plainBytes = new Uint8Array(input); + + // 1-time DEK for this payload const dek = await crypto.subtle.generateKey(AES_GCM, true, [ "encrypt", "decrypt", ]); - // encrypt the plaintext with the DEK + // encrypt the content with the DEK const contentIv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv: contentIv }, dek, - enc.encode(plaintext), + plainBytes, ); - // wrap the DEK with the Master Key (for self access) + // wrap the DEK with the Master Key (for self/owner access) const dekIv = crypto.getRandomValues(new Uint8Array(12)); const wrappedDek = await crypto.subtle.wrapKey("raw", dek, masterKey, { name: "AES-GCM", @@ -94,8 +118,129 @@ export async function encryptLetter( const rawDek = await crypto.subtle.exportKey("raw", dek); return { - encrypted_content: packWithIv(contentIv, ciphertext), + encryptedContent: packWithIv(contentIv, ciphertext), encrypted_dek: packWithIv(dekIv, wrappedDek), sharingKey: toBase64(new Uint8Array(rawDek)), }; } + +async function openEnvelope( + encryptedContent: string, + encrypted_dek: string, + masterKey: CryptoKey, +): Promise> { + // unwrap the DEK using the master key + const [dekIv, wrappedDek] = unpackWithIv(encrypted_dek); + const dek = await crypto.subtle.unwrapKey( + "raw", + wrappedDek, + masterKey, + { name: "AES-GCM", iv: dekIv }, + AES_GCM, + false, + ["decrypt"], + ); + + // decrypt the content with the recovered DEK + const [contentIv, ciphertext] = unpackWithIv(encryptedContent); + const plainBytes = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: contentIv }, + dek, + ciphertext, + ); + + return new Uint8Array(plainBytes); +} + +/* + * Letter functions + */ +export async function encryptLetter( + plaintext: string, + masterKey: CryptoKey, +): Promise { + const { encryptedContent, encrypted_dek, sharingKey } = await sealEnvelope( + new TextEncoder().encode(plaintext), + masterKey, + ); + + return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; +} + +export async function decryptLetter( + { encrypted_content, encrypted_dek }: EncryptedLetter, + masterKey: CryptoKey, +): Promise { + const plainBytes = await openEnvelope( + encrypted_content, + encrypted_dek, + masterKey, + ); + return new TextDecoder().decode(plainBytes); +} + +/* + * Metadata functions + */ +export async function encryptMetadata( + metadata: Record, + masterKey: CryptoKey, +): Promise { + const { encryptedContent, encrypted_dek, sharingKey } = await sealEnvelope( + new TextEncoder().encode(JSON.stringify(metadata)), + masterKey, + ); + + return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; +} + +export async function decryptMetadata( + encrypted_metadata: EncryptedLetter, + masterKey: CryptoKey, +): Promise> { + const plainBytes = await openEnvelope( + encrypted_metadata.encrypted_content, + encrypted_metadata.encrypted_dek, + masterKey, + ); + return JSON.parse(new TextDecoder().decode(plainBytes)); +} + +/* + * Image functions + */ +export async function encryptImage( + file: File, + masterKey: CryptoKey, +): Promise { + const plainBytes = new Uint8Array(await file.arrayBuffer()); + const { encryptedContent, encrypted_dek, sharingKey } = await sealEnvelope( + plainBytes, + masterKey, + ); + + return { + encryptedBlob: new Blob([fromBase64(encryptedContent)]), + encrypted_dek, + sharingKey, + }; +} + +export async function decryptImage( + encryptedUrl: string, + encrypted_dek: string, + masterKey: CryptoKey, +): Promise { + // fetch encrypted bytes from server and repack as base64 for openEnvelope + const encryptedBytes = new Uint8Array( + await (await fetch(encryptedUrl)).arrayBuffer(), + ); + const plainBytes = await openEnvelope( + toBase64(encryptedBytes), + encrypted_dek, + masterKey, + ); + + // return as object URL for use in Fabric / + return URL.createObjectURL(new Blob([plainBytes])); +}