diff --git a/frontend/bun.lock b/frontend/bun.lock index 3fda372..aa94fbe 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -16,6 +16,7 @@ "axios": "^1.15.0", "daisyui": "^5.5.19", "fabric": "^7.2.0", + "idb": "^8.0.3", "react": "^19.2.4", "react-dom": "^19.2.4", "react-hook-form": "^7.72.1", @@ -272,6 +273,8 @@ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], diff --git a/frontend/package.json b/frontend/package.json index 413543b..23ba652 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "axios": "^1.15.0", "daisyui": "^5.5.19", "fabric": "^7.2.0", + "idb": "^8.0.3", "react": "^19.2.4", "react-dom": "^19.2.4", "react-hook-form": "^7.72.1", diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 1515067..61e8cab 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -1,15 +1,35 @@ import { useCallback } from "react"; import { api, publicApi } from "../api/apiClient"; import { endpoints } from "../config/endpoints"; -import { type UserProfile, useAuthStore } from "../store/useAuthStore"; +import type { UserProfile } from "../store/useAuthStore"; +import { useAuthStore } from "../store/useAuthStore"; +import { useKeyStore } from "../store/useKeyStore"; +import { CryptoUtils } from "../utils/crypto"; +import { + clearMasterKey, + loadMasterKey, + saveMasterKey, +} from "../utils/keystore"; export const useAuth = () => { const { accessToken, user, isInitializing, setAuth, clearAuth } = useAuthStore(); + const { setMasterKey } = useKeyStore(); const isAuthenticated = !!accessToken; - const login = (access: string, profile: UserProfile) => { + // called after successful login — derive & save master key + const login = async ( + access: string, + profile: UserProfile, + password: string, + ) => { + const masterKey = await CryptoUtils.deriveMasterKey( + password, + profile.email, + ); + await saveMasterKey(masterKey); + setMasterKey(masterKey); setAuth(access, profile); }; @@ -18,6 +38,8 @@ export const useAuth = () => { await api.post(endpoints.LOGOUT); } finally { clearAuth(); + setMasterKey(null); + await clearMasterKey(); } }; @@ -34,17 +56,20 @@ export const useAuth = () => { try { // try refresh const { data: refreshData } = await publicApi.post(endpoints.REFRESH); - // fetch user profile with the new access token const { data: userData } = await api.get(endpoints.ME, { headers: { Authorization: `Bearer ${refreshData.access}` }, }); - // update auth details in memory setAuth(refreshData.access, userData); - } catch (err) { - console.error("Initialization failed:", err); + + // restore master key from IndexedDB + const masterKey = await loadMasterKey(); + if (masterKey) setMasterKey(masterKey); + } catch { clearAuth(); + setMasterKey(null); + await clearMasterKey(); } - }, []); + }, [setMasterKey]); return { isAuthenticated, diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index a9aa38e..c7d25e6 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -42,7 +42,7 @@ export default function Login() { headers: { Authorization: `Bearer ${authData.access}` }, }); - login(authData.access, userData); + login(authData.access, userData, data.password); navigate(ROUTES.DRAWER); } catch (err) { diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts index 8a7814d..f6bead2 100644 --- a/frontend/src/utils/crypto.ts +++ b/frontend/src/utils/crypto.ts @@ -11,70 +11,7 @@ export interface EncryptedLetter { 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), "")); - -// 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); - packed.set(new Uint8Array(data), iv.length); - 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 a password and email (salt). - * Deterministic — same credentials always produce the same key. - */ -export async function deriveMasterKey( - password: string, - email: string, -): Promise { - const enc = new TextEncoder(); - - const baseKey = await crypto.subtle.importKey( - "raw", - enc.encode(password), - "PBKDF2", - false, - ["deriveKey"], - ); - - return crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: enc.encode(email.toLowerCase()), - iterations: PBKDF2_ITERATIONS, - hash: "SHA-256", - }, - baseKey, - AES_GCM, - false, - ["encrypt", "decrypt", "wrapKey", "unwrapKey"], - ); + filename: string; } /* @@ -86,161 +23,224 @@ interface SealedEnvelope { sharingKey: string; } -async function sealEnvelope( - input: Uint8Array, - masterKey: CryptoKey, -): Promise { - // copy into a fresh ArrayBuffer — WebCrypto requires ArrayBuffer-backed arrays - const plainBytes = new Uint8Array(input); +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 }; - // 1-time DEK for this payload - const dek = await crypto.subtle.generateKey(AES_GCM, true, [ - "encrypt", - "decrypt", - ]); + async initialize() { + this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_GCM, true, [ + "encrypt", + "decrypt", + ]); + } - // 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, - plainBytes, - ); + // base64 conversion for transit + toBase64 = (buf: Uint8Array): string => + btoa(buf.reduce((s, b) => s + String.fromCharCode(b), "")); - // 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", - iv: dekIv, - }); - - // export raw DEK for the share URL (recipient access, no master key needed) - const rawDek = await crypto.subtle.exportKey("raw", dek); - - return { - encryptedContent: packWithIv(contentIv, ciphertext), - encrypted_dek: packWithIv(dekIv, wrappedDek), - sharingKey: toBase64(new Uint8Array(rawDek)), + // explicit loop ensures Uint8Array (not ArrayBufferLike) + 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; }; -} -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, + // 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); }; -} -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, - ); + // split IV (first 12 bytes) back out from a packed base64 bundle + unpackWithIv = ( + b64: string, + ): [Uint8Array, Uint8Array] => { + const buf = this.fromBase64(b64); // ArrayBuffer-backed, so buf.buffer is ArrayBuffer + return [new Uint8Array(buf.buffer, 0, 12), new Uint8Array(buf.buffer, 12)]; + }; - // return as object URL for use in Fabric / - return URL.createObjectURL(new Blob([plainBytes])); + /** + * Derives a Master Key from a password and email (salt). + * Deterministic — same credentials always produce the same key. + */ + public static async deriveMasterKey( + password: string, + email: string, + ): Promise { + const enc = new TextEncoder(); + + const baseKey = await crypto.subtle.importKey( + "raw", + enc.encode(password), + "PBKDF2", + false, + ["deriveKey"], + ); + + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: enc.encode(email.toLowerCase()), + iterations: CryptoUtils.PBKDF2_ITERATIONS, + hash: "SHA-256", + }, + baseKey, + CryptoUtils.AES_GCM, + false, + ["encrypt", "decrypt", "wrapKey", "unwrapKey"], + ); + } + + private async sealEnvelope( + input: Uint8Array, + masterKey: CryptoKey, + ): Promise { + // copy into a fresh ArrayBuffer — WebCrypto requires ArrayBuffer-backed arrays + const plainBytes = new Uint8Array(input); + + // encrypt the content with the DEK + const contentIv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: contentIv }, + this.dek, + plainBytes, + ); + + // 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", this.dek, masterKey, { + name: "AES-GCM", + 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), + sharingKey: this.toBase64(new Uint8Array(rawDek)), + }; + } + + private async openEnvelope( + encryptedContent: string, + encrypted_dek: string, + masterKey: CryptoKey, + ): Promise> { + // unwrap the DEK using the master key + const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek); + const dek = await crypto.subtle.unwrapKey( + "raw", + wrappedDek, + masterKey, + { name: "AES-GCM", iv: dekIv }, + CryptoUtils.AES_GCM, + false, + ["decrypt"], + ); + + // decrypt the content with the recovered DEK + const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent); + const plainBytes = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: contentIv }, + dek, + ciphertext, + ); + + return new Uint8Array(plainBytes); + } + + /* + * Letter functions + */ + public async encryptLetter( + plaintext: string, + masterKey: CryptoKey, + ): Promise { + const { encryptedContent, encrypted_dek, sharingKey } = + await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey); + + return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; + } + + public async decryptLetter( + { encrypted_content, encrypted_dek }: EncryptedLetter, + masterKey: CryptoKey, + ): Promise { + const plainBytes = await this.openEnvelope( + encrypted_content, + encrypted_dek, + masterKey, + ); + return new TextDecoder().decode(plainBytes); + } + + /* + * Metadata functions + */ + public async encryptMetadata( + metadata: Record, + masterKey: CryptoKey, + ): Promise { + const { encryptedContent, encrypted_dek, sharingKey } = + await this.sealEnvelope( + new TextEncoder().encode(JSON.stringify(metadata)), + masterKey, + ); + + return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; + } + + public async decryptMetadata( + encrypted_metadata: EncryptedLetter, + masterKey: CryptoKey, + ): Promise> { + const plainBytes = await this.openEnvelope( + encrypted_metadata.encrypted_content, + encrypted_metadata.encrypted_dek, + masterKey, + ); + return JSON.parse(new TextDecoder().decode(plainBytes)); + } + + /* + * Image functions + */ + public async encryptImage( + file: File, + masterKey: CryptoKey, + ): Promise { + const plainBytes = new Uint8Array(await file.arrayBuffer()); + const { encryptedContent, encrypted_dek } = await this.sealEnvelope( + plainBytes, + masterKey, + ); + + return { + encryptedBlob: new Blob([this.fromBase64(encryptedContent)]), + encrypted_dek, + filename: `${crypto.randomUUID()}.bin`, + }; + } + + public async decryptImage( + encryptedBlob: Blob, + encrypted_dek: string, + masterKey: CryptoKey, + ): Promise { + const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer()); + const plainBytes = await this.openEnvelope( + this.toBase64(encryptedBytes), + encrypted_dek, + masterKey, + ); + + // return as object URL for use in Fabric / + return URL.createObjectURL(new Blob([plainBytes])); + } } diff --git a/frontend/src/utils/keystore.ts b/frontend/src/utils/keystore.ts new file mode 100644 index 0000000..c7e12c7 --- /dev/null +++ b/frontend/src/utils/keystore.ts @@ -0,0 +1,17 @@ +import { openDB } from "idb"; + +// we use this to store master key in browser - secure and good UX +const db = openDB("piku-keys", 1, { + upgrade(db) { + db.createObjectStore("master-key"); + }, +}); + +export const saveMasterKey = async (key: CryptoKey) => + (await db).put("master-key", key, "masterKey"); + +export const loadMasterKey = async (): Promise => + (await db).get("master-key", "masterKey") ?? null; + +export const clearMasterKey = async () => + (await db).delete("master-key", "masterKey");