diff --git a/frontend/src/hooks/useAuth.test.ts b/frontend/src/hooks/useAuth.test.ts index 1bf44b8..a4422f2 100644 --- a/frontend/src/hooks/useAuth.test.ts +++ b/frontend/src/hooks/useAuth.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mockMasterKey } from "../../test/fixtures/auth.fixture"; import { mockUser } from "../../test/fixtures/user.fixture"; import { server } from "../../test/mocks/server"; import { useAuthStore } from "../store/useAuthStore"; @@ -13,26 +14,24 @@ import { } from "../utils/keystore"; import { useAuth } from "./useAuth"; +vi.mock("../utils/crypto"); +vi.mock("../utils/keystore"); + const API_URL = "http://piku-server"; -vi.mock("../utils/crypto", () => ({ - CryptoUtils: { - deriveMasterKey: vi - .fn() - .mockResolvedValue({ type: "secret" } as unknown as CryptoKey), - }, -})); - -vi.mock("../utils/keystore", () => ({ - saveMasterKey: vi.fn().mockResolvedValue(undefined), - loadMasterKey: vi - .fn() - .mockResolvedValue({ type: "secret" } as unknown as CryptoKey), - clearMasterKey: vi.fn().mockResolvedValue(undefined), -})); - beforeEach(() => { vi.clearAllMocks(); + + // hack to set up mock implementations using fixtures + vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({ + masterKey: mockMasterKey, + authHash: "mock-auth-hash", + }); + + vi.mocked(loadMasterKey).mockResolvedValue(mockMasterKey); + vi.mocked(saveMasterKey).mockResolvedValue(undefined); + vi.mocked(clearMasterKey).mockResolvedValue(undefined); + useAuthStore.setState({ accessToken: null, user: null, @@ -61,34 +60,21 @@ describe("isAuthenticated", () => { }); describe("login", () => { - it("should derive the master key using the provided credentials", async () => { + it("should persist the provided master key to IndexedDB", async () => { const { result } = renderHook(() => useAuth()); await act(async () => { - await result.current.login("access-token", mockUser, "test-password"); + await result.current.login("access-token", mockUser, mockMasterKey); }); - expect(CryptoUtils.deriveMasterKey).toHaveBeenCalledWith( - "test-password", - mockUser.email, - ); - }); - - it("should persist the derived master key to IndexedDB", async () => { - const { result } = renderHook(() => useAuth()); - - await act(async () => { - await result.current.login("access-token", mockUser, "my-password"); - }); - - expect(saveMasterKey).toHaveBeenCalledTimes(1); + expect(saveMasterKey).toHaveBeenCalledWith(mockMasterKey); }); it("should update the store with the access token and user profile", async () => { const { result } = renderHook(() => useAuth()); await act(async () => { - await result.current.login("my-access-token", mockUser, "my-password"); + await result.current.login("my-access-token", mockUser, mockMasterKey); }); expect(useAuthStore.getState().accessToken).toBe("my-access-token"); @@ -99,7 +85,7 @@ describe("login", () => { const { result } = renderHook(() => useAuth()); await act(async () => { - await result.current.login("token", mockUser, "my-password"); + await result.current.login("token", mockUser, mockMasterKey); }); expect(useKeyStore.getState().masterKey).not.toBeNull(); diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 07a2278..682b613 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -4,7 +4,6 @@ import { endpoints } from "../config/endpoints"; import type { UserProfile } from "../store/useAuthStore"; import { useAuthStore } from "../store/useAuthStore"; import { useKeyStore } from "../store/useKeyStore"; -import { CryptoUtils } from "../utils/crypto"; import { clearMasterKey, loadMasterKey, @@ -18,16 +17,12 @@ export const useAuth = () => { const isAuthenticated = !!accessToken; - // called after successful login — derive & save master key - const login = async ( + // called after successful login — save master key + const setAuthStore = async ( access: string, profile: UserProfile, - password: string, + masterKey: CryptoKey, ) => { - const masterKey = await CryptoUtils.deriveMasterKey( - password, - profile.email, - ); await saveMasterKey(masterKey); setMasterKey(masterKey); setAuth(access, profile); @@ -76,7 +71,7 @@ export const useAuth = () => { isAuthenticated, user, isInitializing, - login, + setAuthStore, logout, initialize, }; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 5b73b13..075fa3a 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -10,6 +10,7 @@ import FormField from "../components/ui/FormField"; import { endpoints } from "../config/endpoints"; import { ROUTES } from "../config/routes"; import { useAuth } from "../hooks/useAuth"; +import { CryptoUtils } from "../utils/crypto"; const loginSchema = z.object({ email: z.string().email("Please enter a valid email"), @@ -22,7 +23,7 @@ export default function Login() { const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); const [apiError, setApiError] = useState(null); - const { login } = useAuth(); + const { setAuthStore } = useAuth(); const { register, @@ -36,13 +37,24 @@ export default function Login() { setIsLoading(true); setApiError(null); try { - const { data: authData } = await publicApi.post(endpoints.LOGIN, data); + // client side key derivation for 0 knowledge + const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle( + data.password, + data.email, + ); + + // send just the authHash as the password to the server + const { data: authData } = await publicApi.post(endpoints.LOGIN, { + email: data.email, + password: authHash, + }); const { data: userData } = await api.get(endpoints.ME, { headers: { Authorization: `Bearer ${authData.access}` }, }); - login(authData.access, userData, data.password); + // store the auth related data + setAuthStore(authData.access, userData, masterKey); navigate(ROUTES.DRAWER); } catch (err) { diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 7fffa88..d8726c6 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -10,6 +10,7 @@ import Logo from "../components/Logo"; import FormField from "../components/ui/FormField"; import { endpoints } from "../config/endpoints"; import { ROUTES } from "../config/routes"; +import { CryptoUtils } from "../utils/crypto"; // validation logic const registerSchema = z @@ -43,10 +44,16 @@ export default function Register() { setIsLoading(true); setApiError(null); try { + // We generate the key bundle here to get the authHash (password) for the server. + const { authHash } = await CryptoUtils.deriveKeyBundle( + data.password, + data.email, + ); + await publicApi.post(endpoints.REGISTER, { full_name: data.full_name, email: data.email, - password: data.password, + password: authHash, }); navigate(ROUTES.VERIFY_EMAIL); } catch (err) { diff --git a/frontend/src/utils/crypto.test.ts b/frontend/src/utils/crypto.test.ts index feaef4b..bcd282f 100644 --- a/frontend/src/utils/crypto.test.ts +++ b/frontend/src/utils/crypto.test.ts @@ -3,43 +3,65 @@ import { CryptoUtils } from "./crypto"; let utils: CryptoUtils; -describe("deriveMasterKey", () => { +describe("deriveKeyBundle", () => { beforeEach(async () => { utils = new CryptoUtils(); await utils.initialize(); }); - it("should generate a valid CryptoKey instance", async () => { - const key = await CryptoUtils.deriveMasterKey("password", "test@test.com"); + it("should generate a valid CryptoKey and a 64-char hex authHash", async () => { + const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle( + "password", + "test@test.com", + ); - expect(key.type).toBe("secret"); - expect(key).toBeInstanceOf(CryptoKey); + expect(masterKey.type).toBe("secret"); + expect(masterKey).toBeInstanceOf(CryptoKey); + expect(authHash).toHaveLength(64); // SHA-256 hex + expect(typeof authHash).toBe("string"); }); - it("should produce identical keys for identical credentials (deterministic)", async () => { - const keyA = await CryptoUtils.deriveMasterKey("password", "user@me.com"); - const keyB = await CryptoUtils.deriveMasterKey("password", "USER@me.com"); - const secret = "shared-secret"; + it("should produce identical bundles for identical credentials (deterministic)", async () => { + const bundleA = await CryptoUtils.deriveKeyBundle( + "password", + "user@me.com", + ); + const bundleB = await CryptoUtils.deriveKeyBundle( + "password", + "user@me.com", + ); - const encryptedContent = await utils.encryptLetter(secret, keyA); - const decryptedContent = await utils.decryptLetter(encryptedContent, keyB); + expect(bundleA.authHash).toBe(bundleB.authHash); + + const secret = "shared-secret"; + const encryptedContent = await utils.encryptLetter( + secret, + bundleA.masterKey, + ); + const decryptedContent = await utils.decryptLetter( + encryptedContent, + bundleB.masterKey, + ); expect(decryptedContent).toBe(secret); }); - it("should produce different keys for different users", async () => { - const keyA = await CryptoUtils.deriveMasterKey( + it("should produce different keys and hashes for different users", async () => { + const bundleA = await CryptoUtils.deriveKeyBundle( "password", "test1@gmail.com", ); - const keyB = await CryptoUtils.deriveMasterKey( + const bundleB = await CryptoUtils.deriveKeyBundle( "password", "test2@gmail.com", ); - const encrypted = await utils.encryptLetter("secret", keyA); + expect(bundleA.authHash).not.toBe(bundleB.authHash); - await expect(utils.decryptLetter(encrypted, keyB)).rejects.toThrow(); + const encrypted = await utils.encryptLetter("secret", bundleA.masterKey); + await expect( + utils.decryptLetter(encrypted, bundleB.masterKey), + ).rejects.toThrow(); }); }); @@ -47,7 +69,11 @@ describe("encryptLetter / decryptLetter", () => { let masterKey: CryptoKey; beforeEach(async () => { - masterKey = await CryptoUtils.deriveMasterKey("password", "test@test.com"); + const bundle = await CryptoUtils.deriveKeyBundle( + "password", + "test@test.com", + ); + masterKey = bundle.masterKey; }); it("should restore the original plaintext after a roundtrip", async () => { @@ -80,7 +106,11 @@ describe("encryptLetter / decryptLetter", () => { describe("encryptMetadata / decryptMetadata", () => { let masterKey: CryptoKey; beforeEach(async () => { - masterKey = await CryptoUtils.deriveMasterKey("password", "test@test.com"); + const bundle = await CryptoUtils.deriveKeyBundle( + "password", + "test@test.com", + ); + masterKey = bundle.masterKey; }); it("should successfully encrypt and decrypt object content", async () => { @@ -100,7 +130,11 @@ describe("encryptMetadata / decryptMetadata", () => { describe("encryptImage / decryptImage", () => { let masterKey: CryptoKey; beforeEach(async () => { - masterKey = await CryptoUtils.deriveMasterKey("password", "test@test.com"); + const bundle = await CryptoUtils.deriveKeyBundle( + "password", + "test@test.com", + ); + masterKey = bundle.masterKey; }); it("should transform a File into an encrypted .bin Blob", async () => { @@ -138,7 +172,8 @@ describe("encryptImage / decryptImage", () => { describe("Sharing Key Decryption (TDD)", () => { let masterKey: CryptoKey; beforeEach(async () => { - masterKey = await CryptoUtils.deriveMasterKey("pass", "salt"); + const bundle = await CryptoUtils.deriveKeyBundle("password", "salt"); + masterKey = bundle.masterKey; }); it("should decrypt a letter using ONLY the sharing key", async () => { diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts index 4f96721..18e760d 100644 --- a/frontend/src/utils/crypto.ts +++ b/frontend/src/utils/crypto.ts @@ -65,34 +65,54 @@ export class CryptoUtils { }; /** - * Derives a Master Key from a password + email (salt). - * Same credentials = same key. + * Derives a Key Bundle (MasterKey + AuthHash) from a password + email. + * Absolute zero knowledge!! */ - public static async deriveMasterKey( + public static async deriveKeyBundle( password: string, email: string, - ): Promise { + ): Promise<{ masterKey: CryptoKey; authHash: string }> { const enc = new TextEncoder(); + const salt = enc.encode(email.toLowerCase()); + const baseKey = await crypto.subtle.importKey( "raw", enc.encode(password), "PBKDF2", false, - ["deriveKey"], + ["deriveBits", "deriveKey"], ); - return crypto.subtle.deriveKey( + const masterSeed = await crypto.subtle.deriveBits( { name: "PBKDF2", - salt: enc.encode(email.toLowerCase()), + salt, iterations: CryptoUtils.PBKDF2_ITERATIONS, hash: "SHA-256", }, baseKey, + 512, // 512 bits to split + ); + + // first 256 bits for MasterKey, last 256 bits for AuthHash + const masterKeyBytes = masterSeed.slice(0, 32); + const authHashBytes = masterSeed.slice(32, 64); + + // Create the MasterKey for client-side encryption + const masterKey = await crypto.subtle.importKey( + "raw", + masterKeyBytes, CryptoUtils.AES_GCM, 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(""); + + return { masterKey, authHash }; } // Internal helper to encrypt data and wrap the key diff --git a/frontend/src/utils/keystore.test.ts b/frontend/src/utils/keystore.test.ts index e491d23..52ca09a 100644 --- a/frontend/src/utils/keystore.test.ts +++ b/frontend/src/utils/keystore.test.ts @@ -8,22 +8,23 @@ afterEach(async () => { }); async function makeMasterKey() { - return CryptoUtils.deriveMasterKey("test-password", "test@example.com"); + return await CryptoUtils.deriveKeyBundle("test-password", "test@example.com"); } describe("keystore", () => { it("should save and load a CryptoKey successfully", async () => { const key = await makeMasterKey(); - await saveMasterKey(key); + await saveMasterKey(key.masterKey); const keyfromMemory = await loadMasterKey(); expect(keyfromMemory).toBeInstanceOf(CryptoKey); - expect(keyfromMemory).toEqual(key); + expect(keyfromMemory).toEqual(key.masterKey); }); it("should remove the stored key from memory", async () => { - await saveMasterKey(await makeMasterKey()); + const masterKey = await makeMasterKey(); + await saveMasterKey(masterKey.masterKey); await clearMasterKey(); const keyfromMemory = await loadMasterKey(); diff --git a/frontend/src/utils/letterLogic.test.ts b/frontend/src/utils/letterLogic.test.ts index a4aca3d..b068176 100644 --- a/frontend/src/utils/letterLogic.test.ts +++ b/frontend/src/utils/letterLogic.test.ts @@ -23,10 +23,11 @@ describe("letterLogic image helpers", () => { let crypto: CryptoUtils; beforeEach(async () => { - masterKey = await CryptoUtils.deriveMasterKey( + const keyBundle = await CryptoUtils.deriveKeyBundle( "password123", "test@example.com", ); + masterKey = keyBundle.masterKey; crypto = new CryptoUtils(); await crypto.initialize(); vi.clearAllMocks(); diff --git a/frontend/test/fixtures/auth.fixture.ts b/frontend/test/fixtures/auth.fixture.ts new file mode 100644 index 0000000..82ee654 --- /dev/null +++ b/frontend/test/fixtures/auth.fixture.ts @@ -0,0 +1,6 @@ +export const mockMasterKey: CryptoKey = { + type: "secret", + algorithm: { name: "AES-GCM" }, + extractable: false, + usages: ["encrypt", "decrypt"], +}; diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..1cb9678 --- /dev/null +++ b/run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +(podman compose up -d) & +(cd backend && uv run manage.py runserver) & +(cd frontend && bun run dev)