refactor: simplify crypto utils

This commit is contained in:
ramvignesh-b
2026-04-29 22:19:00 +05:30
parent d9827c9e82
commit b9716d368d
2 changed files with 116 additions and 61 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ describe("deriveKeyBundle", () => {
expect(masterKey.type).toBe("secret"); expect(masterKey.type).toBe("secret");
expect(masterKey).toBeInstanceOf(CryptoKey); expect(masterKey).toBeInstanceOf(CryptoKey);
expect(authHash).toHaveLength(64); // SHA-256 hex expect(authHash).toHaveLength(64);
expect(typeof authHash).toBe("string"); expect(typeof authHash).toBe("string");
}); });
@@ -216,7 +216,7 @@ describe("extractSharingKey", () => {
}); });
it("extracted key should decrypt the ciphertext produced by encryptLetter", async () => { 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 encrypted = await utils.encryptLetter(plaintext, masterKey);
const extracted = await utils.extractSharingKey( const extracted = await utils.extractSharingKey(
+114 -59
View File
@@ -7,6 +7,7 @@ export interface EncryptedLetter {
export interface EncryptedLetterMetadata { export interface EncryptedLetterMetadata {
encrypted_content: string; encrypted_content: string;
encrypted_dek: string; encrypted_dek: string;
sharingKey?: string | null;
} }
export interface EncryptedImageUpload { export interface EncryptedImageUpload {
@@ -21,58 +22,87 @@ interface SealedEnvelope {
sharingKey: string; 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 { export class CryptoUtils {
private dek: CryptoKey = {} as CryptoKey; private dek!: CryptoKey;
private static readonly PBKDF2_ITERATIONS = 100_000; private static readonly PBKDF2_ITERATIONS = 600_000;
private static readonly AES_GCM = { name: "AES-GCM", length: 256 }; // 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() { async initialize() {
this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_GCM, true, [ this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_ALGO, true, [
"encrypt", "encrypt",
"decrypt", "decrypt",
]); ]);
} }
// base64 conversion for transit private toBase64 = (buffer: Uint8Array): string => {
toBase64 = (buf: Uint8Array): string => // convert buffer to raw string
btoa(buf.reduce((s, b) => s + String.fromCharCode(b), "")); let binaryFileString = "";
for (let i = 0; i < buffer.byteLength; i++) {
binaryFileString += String.fromCharCode(buffer[i]);
}
return btoa(binaryFileString);
};
fromBase64 = (b64: string): Uint8Array<ArrayBuffer> => { private fromBase64 = (b64String: string): Uint8Array<ArrayBuffer> => {
const str = atob(b64); const decodedString = atob(b64String);
const arr = new Uint8Array(str.length); const arr = new Uint8Array(decodedString.length);
for (let i = 0; i < str.length; i++) arr[i] = str.charCodeAt(i); for (let i = 0; i < decodedString.length; i++)
arr[i] = decodedString.charCodeAt(i);
return arr; return arr;
}; };
// bundle IV + data into a single base64 string // Required structure: [12 bytes IV][Cipher text][16 bytes Auth Tag]
packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => { // NOTE: Web Crypto API auto appends the auth tag, so we focus on IV and cipher
const packed = new Uint8Array(iv.length + data.byteLength); private packWithIv = (iv: Uint8Array, ciphertext: ArrayBuffer): string => {
packed.set(iv); // create a buffer large enough to hold both iv and cipher text (12 + x bytes)
packed.set(new Uint8Array(data), iv.length); const combinedPayload = new Uint8Array(
return this.toBase64(packed); 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 = ( // For decryption: extracts the IV and the data from the base64 string, easy because we know the size of iv already.
b64: string, private unpackWithIv = (
): [Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>] => { encodedString: string,
const buf = this.fromBase64(b64); ): { iv: Uint8Array<ArrayBuffer>; ciphertext: Uint8Array<ArrayBuffer> } => {
return [new Uint8Array(buf.buffer, 0, 12), new Uint8Array(buf.buffer, 12)]; // 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( public static async deriveKeyBundle(
password: string, password: string,
email: string, email: string,
): Promise<{ masterKey: CryptoKey; authHash: string }> { ): Promise<{ masterKey: CryptoKey; authHash: string }> {
const enc = new TextEncoder(); const encoder = new TextEncoder();
const salt = enc.encode(email.toLowerCase()); const salt = encoder.encode(email.toLowerCase());
const baseKey = await crypto.subtle.importKey( const baseKey = await crypto.subtle.importKey(
"raw", "raw",
enc.encode(password), encoder.encode(password),
"PBKDF2", "PBKDF2",
false, false,
["deriveBits", "deriveKey"], ["deriveBits", "deriveKey"],
@@ -89,53 +119,67 @@ export class CryptoUtils {
512, 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 masterKeyBytes = masterSeed.slice(0, 32);
const authHashBytes = masterSeed.slice(32, 64); 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( const masterKey = await crypto.subtle.importKey(
"raw", "raw",
masterKeyBytes, masterKeyBytes,
CryptoUtils.AES_GCM, CryptoUtils.AES_ALGO,
false, false,
["encrypt", "decrypt", "wrapKey", "unwrapKey"], ["encrypt", "decrypt", "wrapKey", "unwrapKey"],
); );
// Create the hex AuthHash for server-side verification // convert bytes in to hex string
const authHash = Array.from(new Uint8Array(authHashBytes)) let authHash = "";
.map((b) => b.toString(16).padStart(2, "0")) const authHashBuffer = new Uint8Array(authHashBytes);
.join("");
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 }; 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( private async sealEnvelope(
input: Uint8Array, input: Uint8Array,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<SealedEnvelope> { ): Promise<SealedEnvelope> {
if (!this.dek) {
throw new Error("DEK is not available (forgot to .initialize()?)");
}
const plainBytes = new Uint8Array(input); const plainBytes = new Uint8Array(input);
const contentIV = crypto.getRandomValues(new Uint8Array(12)); const contentIv = crypto.getRandomValues(new Uint8Array(12));
const dekIV = crypto.getRandomValues(new Uint8Array(12)); const dekIv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt( const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: contentIV }, { name: CryptoUtils.AES_ALGO.name, iv: contentIv },
this.dek, this.dek,
plainBytes, 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, { const wrappedDek = await crypto.subtle.wrapKey("raw", this.dek, masterKey, {
name: "AES-GCM", name: CryptoUtils.AES_ALGO.name,
iv: dekIV, iv: dekIv,
}); });
// export raw DEK for the share URL (recipient access, no master key needed) // export raw DEK for the share URL (recipient access, no master key needed)
const rawDek = await crypto.subtle.exportKey("raw", this.dek); const rawDek = await crypto.subtle.exportKey("raw", this.dek);
return { return {
encryptedContent: this.packWithIv(contentIV, ciphertext), encryptedContent: this.packWithIv(contentIv, ciphertext),
encrypted_dek: this.packWithIv(dekIV, wrappedDek), encrypted_dek: this.packWithIv(dekIv, wrappedDek),
sharingKey: this.toBase64(new Uint8Array(rawDek)), sharingKey: this.toBase64(new Uint8Array(rawDek)),
}; };
} }
@@ -146,20 +190,21 @@ export class CryptoUtils {
encrypted_dek: string, encrypted_dek: string,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<Uint8Array<ArrayBuffer>> { ): Promise<Uint8Array<ArrayBuffer>> {
const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek); const { iv: dekIv, ciphertext: wrappedDek } =
this.unpackWithIv(encrypted_dek);
const dek = await crypto.subtle.unwrapKey( const dek = await crypto.subtle.unwrapKey(
"raw", "raw",
wrappedDek, wrappedDek,
masterKey, masterKey,
{ name: "AES-GCM", iv: dekIv }, { name: CryptoUtils.AES_ALGO.name, iv: dekIv },
CryptoUtils.AES_GCM, CryptoUtils.AES_ALGO,
false, false,
["decrypt"], ["decrypt"],
); );
const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent); const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent);
const plainBytes = await crypto.subtle.decrypt( const plainBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: contentIv }, { name: CryptoUtils.AES_ALGO.name, iv: contentIv },
dek, dek,
ciphertext, ciphertext,
); );
@@ -175,14 +220,14 @@ export class CryptoUtils {
const dek = await crypto.subtle.importKey( const dek = await crypto.subtle.importKey(
"raw", "raw",
dekBytes, dekBytes,
CryptoUtils.AES_GCM, CryptoUtils.AES_ALGO,
false, false,
["decrypt"], ["decrypt"],
); );
const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent); const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent);
const plainBytes = await crypto.subtle.decrypt( const plainBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: contentIv }, { name: CryptoUtils.AES_ALGO.name, iv: contentIv },
dek, dek,
ciphertext, ciphertext,
); );
@@ -199,6 +244,7 @@ export class CryptoUtils {
): Promise<EncryptedLetter> { ): Promise<EncryptedLetter> {
const { encryptedContent, encrypted_dek, sharingKey } = const { encryptedContent, encrypted_dek, sharingKey } =
await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey); await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey);
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
} }
@@ -211,6 +257,7 @@ export class CryptoUtils {
encrypted_dek, encrypted_dek,
masterKey, masterKey,
); );
return new TextDecoder().decode(bytes); return new TextDecoder().decode(bytes);
} }
@@ -222,18 +269,20 @@ export class CryptoUtils {
encrypted_content, encrypted_content,
sharingKey, sharingKey,
); );
return new TextDecoder().decode(bytes); return new TextDecoder().decode(bytes);
} }
public async encryptMetadata( public async encryptMetadata(
metadata: Record<string, any>, metadata: Record<string, any>,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<EncryptedLetter> { ): Promise<EncryptedLetterMetadata> {
const { encryptedContent, encrypted_dek, sharingKey } = const { encryptedContent, encrypted_dek, sharingKey } =
await this.sealEnvelope( await this.sealEnvelope(
new TextEncoder().encode(JSON.stringify(metadata)), new TextEncoder().encode(JSON.stringify(metadata)),
masterKey, masterKey,
); );
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
} }
@@ -246,6 +295,7 @@ export class CryptoUtils {
encrypted_metadata.encrypted_dek, encrypted_metadata.encrypted_dek,
masterKey, masterKey,
); );
return JSON.parse(new TextDecoder().decode(bytes)); return JSON.parse(new TextDecoder().decode(bytes));
} }
@@ -257,6 +307,7 @@ export class CryptoUtils {
encrypted_content, encrypted_content,
sharingKey, sharingKey,
); );
return JSON.parse(new TextDecoder().decode(bytes)); return JSON.parse(new TextDecoder().decode(bytes));
} }
@@ -283,12 +334,13 @@ export class CryptoUtils {
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<string> { ): Promise<string> {
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer()); const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
const bytes = await this.openEnvelope( const plainBytes = await this.openEnvelope(
this.toBase64(encryptedBytes), this.toBase64(encryptedBytes),
encrypted_dek, encrypted_dek,
masterKey, masterKey,
); );
return URL.createObjectURL(new Blob([bytes]));
return URL.createObjectURL(new Blob([plainBytes]));
} }
public async decryptImageWithSharingKey( public async decryptImageWithSharingKey(
@@ -296,28 +348,31 @@ export class CryptoUtils {
sharingKey: string, sharingKey: string,
): Promise<string> { ): Promise<string> {
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer()); const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
const bytes = await this.openEnvelopeWithSharingKey( const plainBytes = await this.openEnvelopeWithSharingKey(
this.toBase64(encryptedBytes), this.toBase64(encryptedBytes),
sharingKey, 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( public async extractSharingKey(
encrypted_dek: string, encrypted_dek: string,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<string> { ): Promise<string> {
const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek); const { iv: dekIv, ciphertext: wrappedDek } =
this.unpackWithIv(encrypted_dek);
const rawDek = await crypto.subtle.unwrapKey( const rawDek = await crypto.subtle.unwrapKey(
"raw", "raw",
wrappedDek, wrappedDek,
masterKey, masterKey,
{ name: "AES-GCM", iv: dekIv }, { name: CryptoUtils.AES_ALGO.name, iv: dekIv },
CryptoUtils.AES_GCM, CryptoUtils.AES_ALGO,
true, true,
["decrypt"], ["decrypt"],
); );
return this.toBase64( return this.toBase64(
new Uint8Array(await crypto.subtle.exportKey("raw", rawDek)), new Uint8Array(await crypto.subtle.exportKey("raw", rawDek)),
); );