refactor: big brain moment - implement envelope encryption for images and metadata with canvas serialization support

This commit is contained in:
Your Name
2026-04-11 20:32:07 +05:30
parent cc9301740c
commit 9b9f1110ca
+170 -25
View File
@@ -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 { export interface EncryptedLetter {
encrypted_content: string; // IV + ciphertext, base64 encrypted_content: string;
encrypted_dek: string; // IV + wrapped DEK, base64 encrypted_dek: string;
sharingKey: string; // raw DEK, base64 (embedded in share URL) sharingKey: string;
}
export interface EncryptedImageUpload {
encryptedBlob: Blob;
encrypted_dek: string;
sharingKey: string;
} }
const PBKDF2_ITERATIONS = 100_000; const PBKDF2_ITERATIONS = 100_000;
const AES_GCM = { name: "AES-GCM", length: 256 } as const; const AES_GCM = { name: "AES-GCM", length: 256 } as const;
// base64 conversion for transit
const toBase64 = (buf: Uint8Array): string => const toBase64 = (buf: Uint8Array): string =>
btoa(buf.reduce((s, b) => s + String.fromCharCode(b), "")); btoa(buf.reduce((s, b) => s + String.fromCharCode(b), ""));
// Prefix the IV to data and base64-encode the result. // explicit loop ensures Uint8Array<ArrayBuffer> (not ArrayBufferLike)
const fromBase64 = (b64: string): Uint8Array<ArrayBuffer> => {
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 packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => {
const packed = new Uint8Array(iv.length + data.byteLength); const packed = new Uint8Array(iv.length + data.byteLength);
packed.set(iv); packed.set(iv);
@@ -24,9 +37,17 @@ const packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => {
return toBase64(packed); return toBase64(packed);
}; };
// split IV (first 12 bytes) back out from a packed base64 bundle
const unpackWithIv = (
b64: string,
): [Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>] => {
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). * Derives a Master Key from a password and email (salt).
* Note: it is deterministic, i.e. the same credentials always produce the same key * Deterministic same credentials always produce the same key.
*/ */
export async function deriveMasterKey( export async function deriveMasterKey(
password: string, password: string,
@@ -56,34 +77,37 @@ export async function deriveMasterKey(
); );
} }
/** /*
* Encrypts a letter using Envelope Encryption. * Wrapper functions
*
* plaintext >> DEK >> encrypted_content
* DEK >> masterKey >> encrypted_dek
* DEK >> raw >> sharingKey
*/ */
export async function encryptLetter( interface SealedEnvelope {
plaintext: string, encryptedContent: string;
masterKey: CryptoKey, encrypted_dek: string;
): Promise<EncryptedLetter> { sharingKey: string;
const enc = new TextEncoder(); }
// 1time DEK for this letter async function sealEnvelope(
input: Uint8Array,
masterKey: CryptoKey,
): Promise<SealedEnvelope> {
// 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, [ const dek = await crypto.subtle.generateKey(AES_GCM, true, [
"encrypt", "encrypt",
"decrypt", "decrypt",
]); ]);
// encrypt the plaintext with the DEK // encrypt the content with the DEK
const contentIv = crypto.getRandomValues(new Uint8Array(12)); const contentIv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt( const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: contentIv }, { name: "AES-GCM", iv: contentIv },
dek, 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 dekIv = crypto.getRandomValues(new Uint8Array(12));
const wrappedDek = await crypto.subtle.wrapKey("raw", dek, masterKey, { const wrappedDek = await crypto.subtle.wrapKey("raw", dek, masterKey, {
name: "AES-GCM", name: "AES-GCM",
@@ -94,8 +118,129 @@ export async function encryptLetter(
const rawDek = await crypto.subtle.exportKey("raw", dek); const rawDek = await crypto.subtle.exportKey("raw", dek);
return { return {
encrypted_content: packWithIv(contentIv, ciphertext), encryptedContent: packWithIv(contentIv, ciphertext),
encrypted_dek: packWithIv(dekIv, wrappedDek), encrypted_dek: packWithIv(dekIv, wrappedDek),
sharingKey: toBase64(new Uint8Array(rawDek)), sharingKey: toBase64(new Uint8Array(rawDek)),
}; };
} }
async function openEnvelope(
encryptedContent: string,
encrypted_dek: string,
masterKey: CryptoKey,
): Promise<Uint8Array<ArrayBuffer>> {
// 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<EncryptedLetter> {
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<string> {
const plainBytes = await openEnvelope(
encrypted_content,
encrypted_dek,
masterKey,
);
return new TextDecoder().decode(plainBytes);
}
/*
* Metadata functions
*/
export async function encryptMetadata(
metadata: Record<string, string>,
masterKey: CryptoKey,
): Promise<EncryptedLetter> {
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<Record<string, string>> {
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<EncryptedImageUpload> {
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<string> {
// 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 / <img>
return URL.createObjectURL(new Blob([plainBytes]));
}