From b9716d368d30783848acf167dc3d12a35a23c618 Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Wed, 29 Apr 2026 22:19:00 +0530 Subject: [PATCH] refactor: simplify crypto utils --- frontend/src/utils/crypto.test.ts | 4 +- frontend/src/utils/crypto.ts | 173 ++++++++++++++++++++---------- 2 files changed, 116 insertions(+), 61 deletions(-) diff --git a/frontend/src/utils/crypto.test.ts b/frontend/src/utils/crypto.test.ts index eff276c..694936d 100644 --- a/frontend/src/utils/crypto.test.ts +++ b/frontend/src/utils/crypto.test.ts @@ -17,7 +17,7 @@ describe("deriveKeyBundle", () => { expect(masterKey.type).toBe("secret"); expect(masterKey).toBeInstanceOf(CryptoKey); - expect(authHash).toHaveLength(64); // SHA-256 hex + expect(authHash).toHaveLength(64); expect(typeof authHash).toBe("string"); }); @@ -216,7 +216,7 @@ describe("extractSharingKey", () => { }); it("extracted key should decrypt the ciphertext produced by encryptLetter", async () => { - const plaintext = "hello from the owner"; + const plaintext = "hello"; const encrypted = await utils.encryptLetter(plaintext, masterKey); const extracted = await utils.extractSharingKey( diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts index 79223c1..5846afa 100644 --- a/frontend/src/utils/crypto.ts +++ b/frontend/src/utils/crypto.ts @@ -7,6 +7,7 @@ export interface EncryptedLetter { export interface EncryptedLetterMetadata { encrypted_content: string; encrypted_dek: string; + sharingKey?: string | null; } export interface EncryptedImageUpload { @@ -21,58 +22,87 @@ interface SealedEnvelope { sharingKey: string; } +// we use a class here to keep track of instantiations (use 1 and the same DEK per letter content and metadata) +// TODO: try refactoring into a pure function for consistency export class CryptoUtils { - private dek: CryptoKey = {} as CryptoKey; - private static readonly PBKDF2_ITERATIONS = 100_000; - private static readonly AES_GCM = { name: "AES-GCM", length: 256 }; + private dek!: CryptoKey; + private static readonly PBKDF2_ITERATIONS = 600_000; + // NOTE: https://www.w3.org/TR/webcrypto/#aes-gcm + private static readonly AES_ALGO = { name: "AES-GCM", length: 256 }; + private static readonly IV_BYTE_LENGTH = 12; - // Generates a fresh Data Encryption Key (DEK) + // NOTE: this MUST be called once, per letter, for all operations in a session to a fresh Data Encryption Key (DEK) async initialize() { - this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_GCM, true, [ + this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_ALGO, true, [ "encrypt", "decrypt", ]); } - // base64 conversion for transit - toBase64 = (buf: Uint8Array): string => - btoa(buf.reduce((s, b) => s + String.fromCharCode(b), "")); + private toBase64 = (buffer: Uint8Array): string => { + // convert buffer to raw string + let binaryFileString = ""; + for (let i = 0; i < buffer.byteLength; i++) { + binaryFileString += String.fromCharCode(buffer[i]); + } + return btoa(binaryFileString); + }; - 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); + private fromBase64 = (b64String: string): Uint8Array => { + const decodedString = atob(b64String); + const arr = new Uint8Array(decodedString.length); + for (let i = 0; i < decodedString.length; i++) + arr[i] = decodedString.charCodeAt(i); return arr; }; - // bundle IV + data into a single base64 string - packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => { - const packed = new Uint8Array(iv.length + data.byteLength); - packed.set(iv); - packed.set(new Uint8Array(data), iv.length); - return this.toBase64(packed); + // Required structure: [12 bytes IV][Cipher text][16 bytes Auth Tag] + // NOTE: Web Crypto API auto appends the auth tag, so we focus on IV and cipher + private packWithIv = (iv: Uint8Array, ciphertext: ArrayBuffer): string => { + // create a buffer large enough to hold both iv and cipher text (12 + x bytes) + const combinedPayload = new Uint8Array( + CryptoUtils.IV_BYTE_LENGTH + ciphertext.byteLength, + ); + + // place the iv at the start + combinedPayload.set(iv, 0); + + // place the ciphertext after the iv + combinedPayload.set(new Uint8Array(ciphertext), CryptoUtils.IV_BYTE_LENGTH); + + // convert the buffer to Base64 for transit + return this.toBase64(combinedPayload); }; - unpackWithIv = ( - b64: string, - ): [Uint8Array, Uint8Array] => { - const buf = this.fromBase64(b64); - return [new Uint8Array(buf.buffer, 0, 12), new Uint8Array(buf.buffer, 12)]; + // For decryption: extracts the IV and the data from the base64 string, easy because we know the size of iv already. + private unpackWithIv = ( + encodedString: string, + ): { iv: Uint8Array; ciphertext: Uint8Array } => { + // decode from base64 to array buffer + const fullBuffer = this.fromBase64(encodedString); + + // extract first 12 bytes for iv + const iv = fullBuffer.slice(0, CryptoUtils.IV_BYTE_LENGTH); + // extract rest for cipher text + const ciphertext = fullBuffer.slice(CryptoUtils.IV_BYTE_LENGTH); + + return { iv: new Uint8Array(iv), ciphertext: new Uint8Array(ciphertext) }; }; /** - * Derives a Key Bundle (MasterKey + AuthHash) from a password + email. + * Derive a key bundle (Masterkey + authHash) from email + (plain) password combo + * WHY?: This is much secure than relying on server to hash and store the password. Also ensures absolute 0 knowledge */ public static async deriveKeyBundle( password: string, email: string, ): Promise<{ masterKey: CryptoKey; authHash: string }> { - const enc = new TextEncoder(); - const salt = enc.encode(email.toLowerCase()); + const encoder = new TextEncoder(); + const salt = encoder.encode(email.toLowerCase()); const baseKey = await crypto.subtle.importKey( "raw", - enc.encode(password), + encoder.encode(password), "PBKDF2", false, ["deriveBits", "deriveKey"], @@ -89,53 +119,67 @@ export class CryptoUtils { 512, ); - // first 256 bits for MasterKey, last 256 bits for AuthHash + // first 256 bits for masterkey, last 256 bits for authHash (password sent in REST) const masterKeyBytes = masterSeed.slice(0, 32); const authHashBytes = masterSeed.slice(32, 64); - // Create the MasterKey for client-side encryption + // Create the masterkey for client-side encryption const masterKey = await crypto.subtle.importKey( "raw", masterKeyBytes, - CryptoUtils.AES_GCM, + CryptoUtils.AES_ALGO, false, ["encrypt", "decrypt", "wrapKey", "unwrapKey"], ); - // Create the hex AuthHash for server-side verification - const authHash = Array.from(new Uint8Array(authHashBytes)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); + // convert bytes in to hex string + let authHash = ""; + const authHashBuffer = new Uint8Array(authHashBytes); + + for (let i = 0; i < authHashBuffer.byteLength; i++) { + // we force every bytes converted to string to be min 2 chars (otherwise 00 0a will be just a and not "000a") + authHash += authHashBuffer[i].toString(16).padStart(2, "0"); + } return { masterKey, authHash }; } + /* + * Envelope Encryption - Decryption + * WHY?: for guest access where we don't have to share the masterkey just the dek. + * This way, raw dek never leaves browser (db stores the encrypted version) + */ + + // encrypt the plaintext with a DEK and then encrypt (wrap) that DEK with the user's masterkey. private async sealEnvelope( input: Uint8Array, masterKey: CryptoKey, ): Promise { + if (!this.dek) { + throw new Error("DEK is not available (forgot to .initialize()?)"); + } const plainBytes = new Uint8Array(input); - const contentIV = crypto.getRandomValues(new Uint8Array(12)); - const dekIV = crypto.getRandomValues(new Uint8Array(12)); + const contentIv = crypto.getRandomValues(new Uint8Array(12)); + const dekIv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( - { name: "AES-GCM", iv: contentIV }, + { name: CryptoUtils.AES_ALGO.name, iv: contentIv }, this.dek, plainBytes, ); - // wrap the DEK with the Master Key (for self/owner access) + // wrap the DEK with the Master Key (for self access) const wrappedDek = await crypto.subtle.wrapKey("raw", this.dek, masterKey, { - name: "AES-GCM", - iv: dekIV, + name: CryptoUtils.AES_ALGO.name, + iv: dekIv, }); // export raw DEK for the share URL (recipient access, no master key needed) const rawDek = await crypto.subtle.exportKey("raw", this.dek); return { - encryptedContent: this.packWithIv(contentIV, ciphertext), - encrypted_dek: this.packWithIv(dekIV, wrappedDek), + encryptedContent: this.packWithIv(contentIv, ciphertext), + encrypted_dek: this.packWithIv(dekIv, wrappedDek), sharingKey: this.toBase64(new Uint8Array(rawDek)), }; } @@ -146,20 +190,21 @@ export class CryptoUtils { encrypted_dek: string, masterKey: CryptoKey, ): Promise> { - const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek); + const { iv: dekIv, ciphertext: wrappedDek } = + this.unpackWithIv(encrypted_dek); const dek = await crypto.subtle.unwrapKey( "raw", wrappedDek, masterKey, - { name: "AES-GCM", iv: dekIv }, - CryptoUtils.AES_GCM, + { name: CryptoUtils.AES_ALGO.name, iv: dekIv }, + CryptoUtils.AES_ALGO, false, ["decrypt"], ); - const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent); + const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent); const plainBytes = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: contentIv }, + { name: CryptoUtils.AES_ALGO.name, iv: contentIv }, dek, ciphertext, ); @@ -175,14 +220,14 @@ export class CryptoUtils { const dek = await crypto.subtle.importKey( "raw", dekBytes, - CryptoUtils.AES_GCM, + CryptoUtils.AES_ALGO, false, ["decrypt"], ); - const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent); + const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent); const plainBytes = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: contentIv }, + { name: CryptoUtils.AES_ALGO.name, iv: contentIv }, dek, ciphertext, ); @@ -199,6 +244,7 @@ export class CryptoUtils { ): Promise { const { encryptedContent, encrypted_dek, sharingKey } = await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey); + return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; } @@ -211,6 +257,7 @@ export class CryptoUtils { encrypted_dek, masterKey, ); + return new TextDecoder().decode(bytes); } @@ -222,18 +269,20 @@ export class CryptoUtils { encrypted_content, sharingKey, ); + return new TextDecoder().decode(bytes); } public async encryptMetadata( metadata: Record, masterKey: CryptoKey, - ): Promise { + ): Promise { const { encryptedContent, encrypted_dek, sharingKey } = await this.sealEnvelope( new TextEncoder().encode(JSON.stringify(metadata)), masterKey, ); + return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; } @@ -246,6 +295,7 @@ export class CryptoUtils { encrypted_metadata.encrypted_dek, masterKey, ); + return JSON.parse(new TextDecoder().decode(bytes)); } @@ -257,6 +307,7 @@ export class CryptoUtils { encrypted_content, sharingKey, ); + return JSON.parse(new TextDecoder().decode(bytes)); } @@ -283,12 +334,13 @@ export class CryptoUtils { masterKey: CryptoKey, ): Promise { const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer()); - const bytes = await this.openEnvelope( + const plainBytes = await this.openEnvelope( this.toBase64(encryptedBytes), encrypted_dek, masterKey, ); - return URL.createObjectURL(new Blob([bytes])); + + return URL.createObjectURL(new Blob([plainBytes])); } public async decryptImageWithSharingKey( @@ -296,28 +348,31 @@ export class CryptoUtils { sharingKey: string, ): Promise { const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer()); - const bytes = await this.openEnvelopeWithSharingKey( + const plainBytes = await this.openEnvelopeWithSharingKey( this.toBase64(encryptedBytes), sharingKey, ); - return URL.createObjectURL(new Blob([bytes])); + + return URL.createObjectURL(new Blob([plainBytes])); } - // Re-derives the sharing key (raw DEK) on demand (browser only, not sent to server). + // derive raw DEK on demand (browser only, not sent to server) for guest access public async extractSharingKey( encrypted_dek: string, masterKey: CryptoKey, ): Promise { - const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek); + const { iv: dekIv, ciphertext: wrappedDek } = + this.unpackWithIv(encrypted_dek); const rawDek = await crypto.subtle.unwrapKey( "raw", wrappedDek, masterKey, - { name: "AES-GCM", iv: dekIv }, - CryptoUtils.AES_GCM, + { name: CryptoUtils.AES_ALGO.name, iv: dekIv }, + CryptoUtils.AES_ALGO, true, ["decrypt"], ); + return this.toBase64( new Uint8Array(await crypto.subtle.exportKey("raw", rawDek)), );