mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
refactor: big brain moment - implement envelope encryption for images and metadata with canvas serialization support
This commit is contained in:
+170
-25
@@ -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]));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user