feat: enhance zero-knowledge authentication by deriving and sending auth hashes to the server

This commit is contained in:
ramvignesh-b
2026-04-14 22:44:42 +05:30
parent 3e5dbbe3f3
commit 967b3a77f8
10 changed files with 146 additions and 79 deletions
+20 -34
View File
@@ -1,6 +1,7 @@
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { mockMasterKey } from "../../test/fixtures/auth.fixture";
import { mockUser } from "../../test/fixtures/user.fixture"; import { mockUser } from "../../test/fixtures/user.fixture";
import { server } from "../../test/mocks/server"; import { server } from "../../test/mocks/server";
import { useAuthStore } from "../store/useAuthStore"; import { useAuthStore } from "../store/useAuthStore";
@@ -13,26 +14,24 @@ import {
} from "../utils/keystore"; } from "../utils/keystore";
import { useAuth } from "./useAuth"; import { useAuth } from "./useAuth";
vi.mock("../utils/crypto");
vi.mock("../utils/keystore");
const API_URL = "http://piku-server"; 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(() => { beforeEach(() => {
vi.clearAllMocks(); 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({ useAuthStore.setState({
accessToken: null, accessToken: null,
user: null, user: null,
@@ -61,34 +60,21 @@ describe("isAuthenticated", () => {
}); });
describe("login", () => { 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()); const { result } = renderHook(() => useAuth());
await act(async () => { await act(async () => {
await result.current.login("access-token", mockUser, "test-password"); await result.current.login("access-token", mockUser, mockMasterKey);
}); });
expect(CryptoUtils.deriveMasterKey).toHaveBeenCalledWith( expect(saveMasterKey).toHaveBeenCalledWith(mockMasterKey);
"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);
}); });
it("should update the store with the access token and user profile", async () => { it("should update the store with the access token and user profile", async () => {
const { result } = renderHook(() => useAuth()); const { result } = renderHook(() => useAuth());
await act(async () => { 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"); expect(useAuthStore.getState().accessToken).toBe("my-access-token");
@@ -99,7 +85,7 @@ describe("login", () => {
const { result } = renderHook(() => useAuth()); const { result } = renderHook(() => useAuth());
await act(async () => { await act(async () => {
await result.current.login("token", mockUser, "my-password"); await result.current.login("token", mockUser, mockMasterKey);
}); });
expect(useKeyStore.getState().masterKey).not.toBeNull(); expect(useKeyStore.getState().masterKey).not.toBeNull();
+4 -9
View File
@@ -4,7 +4,6 @@ import { endpoints } from "../config/endpoints";
import type { UserProfile } from "../store/useAuthStore"; import type { UserProfile } from "../store/useAuthStore";
import { useAuthStore } from "../store/useAuthStore"; import { useAuthStore } from "../store/useAuthStore";
import { useKeyStore } from "../store/useKeyStore"; import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
import { import {
clearMasterKey, clearMasterKey,
loadMasterKey, loadMasterKey,
@@ -18,16 +17,12 @@ export const useAuth = () => {
const isAuthenticated = !!accessToken; const isAuthenticated = !!accessToken;
// called after successful login — derive & save master key // called after successful login — save master key
const login = async ( const setAuthStore = async (
access: string, access: string,
profile: UserProfile, profile: UserProfile,
password: string, masterKey: CryptoKey,
) => { ) => {
const masterKey = await CryptoUtils.deriveMasterKey(
password,
profile.email,
);
await saveMasterKey(masterKey); await saveMasterKey(masterKey);
setMasterKey(masterKey); setMasterKey(masterKey);
setAuth(access, profile); setAuth(access, profile);
@@ -76,7 +71,7 @@ export const useAuth = () => {
isAuthenticated, isAuthenticated,
user, user,
isInitializing, isInitializing,
login, setAuthStore,
logout, logout,
initialize, initialize,
}; };
+15 -3
View File
@@ -10,6 +10,7 @@ import FormField from "../components/ui/FormField";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { ROUTES } from "../config/routes"; import { ROUTES } from "../config/routes";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { CryptoUtils } from "../utils/crypto";
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email("Please enter a valid email"), email: z.string().email("Please enter a valid email"),
@@ -22,7 +23,7 @@ export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string | null>(null); const [apiError, setApiError] = useState<string | null>(null);
const { login } = useAuth(); const { setAuthStore } = useAuth();
const { const {
register, register,
@@ -36,13 +37,24 @@ export default function Login() {
setIsLoading(true); setIsLoading(true);
setApiError(null); setApiError(null);
try { 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, { const { data: userData } = await api.get(endpoints.ME, {
headers: { Authorization: `Bearer ${authData.access}` }, headers: { Authorization: `Bearer ${authData.access}` },
}); });
login(authData.access, userData, data.password); // store the auth related data
setAuthStore(authData.access, userData, masterKey);
navigate(ROUTES.DRAWER); navigate(ROUTES.DRAWER);
} catch (err) { } catch (err) {
+8 -1
View File
@@ -10,6 +10,7 @@ import Logo from "../components/Logo";
import FormField from "../components/ui/FormField"; import FormField from "../components/ui/FormField";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { ROUTES } from "../config/routes"; import { ROUTES } from "../config/routes";
import { CryptoUtils } from "../utils/crypto";
// validation logic // validation logic
const registerSchema = z const registerSchema = z
@@ -43,10 +44,16 @@ export default function Register() {
setIsLoading(true); setIsLoading(true);
setApiError(null); setApiError(null);
try { 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, { await publicApi.post(endpoints.REGISTER, {
full_name: data.full_name, full_name: data.full_name,
email: data.email, email: data.email,
password: data.password, password: authHash,
}); });
navigate(ROUTES.VERIFY_EMAIL); navigate(ROUTES.VERIFY_EMAIL);
} catch (err) { } catch (err) {
+55 -20
View File
@@ -3,43 +3,65 @@ import { CryptoUtils } from "./crypto";
let utils: CryptoUtils; let utils: CryptoUtils;
describe("deriveMasterKey", () => { describe("deriveKeyBundle", () => {
beforeEach(async () => { beforeEach(async () => {
utils = new CryptoUtils(); utils = new CryptoUtils();
await utils.initialize(); await utils.initialize();
}); });
it("should generate a valid CryptoKey instance", async () => { it("should generate a valid CryptoKey and a 64-char hex authHash", async () => {
const key = await CryptoUtils.deriveMasterKey("password", "test@test.com"); const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
"password",
"test@test.com",
);
expect(key.type).toBe("secret"); expect(masterKey.type).toBe("secret");
expect(key).toBeInstanceOf(CryptoKey); 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 () => { it("should produce identical bundles for identical credentials (deterministic)", async () => {
const keyA = await CryptoUtils.deriveMasterKey("password", "user@me.com"); const bundleA = await CryptoUtils.deriveKeyBundle(
const keyB = await CryptoUtils.deriveMasterKey("password", "USER@me.com"); "password",
const secret = "shared-secret"; "user@me.com",
);
const bundleB = await CryptoUtils.deriveKeyBundle(
"password",
"user@me.com",
);
const encryptedContent = await utils.encryptLetter(secret, keyA); expect(bundleA.authHash).toBe(bundleB.authHash);
const decryptedContent = await utils.decryptLetter(encryptedContent, keyB);
const secret = "shared-secret";
const encryptedContent = await utils.encryptLetter(
secret,
bundleA.masterKey,
);
const decryptedContent = await utils.decryptLetter(
encryptedContent,
bundleB.masterKey,
);
expect(decryptedContent).toBe(secret); expect(decryptedContent).toBe(secret);
}); });
it("should produce different keys for different users", async () => { it("should produce different keys and hashes for different users", async () => {
const keyA = await CryptoUtils.deriveMasterKey( const bundleA = await CryptoUtils.deriveKeyBundle(
"password", "password",
"test1@gmail.com", "test1@gmail.com",
); );
const keyB = await CryptoUtils.deriveMasterKey( const bundleB = await CryptoUtils.deriveKeyBundle(
"password", "password",
"test2@gmail.com", "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; let masterKey: CryptoKey;
beforeEach(async () => { 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 () => { it("should restore the original plaintext after a roundtrip", async () => {
@@ -80,7 +106,11 @@ describe("encryptLetter / decryptLetter", () => {
describe("encryptMetadata / decryptMetadata", () => { describe("encryptMetadata / decryptMetadata", () => {
let masterKey: CryptoKey; let masterKey: CryptoKey;
beforeEach(async () => { 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 () => { it("should successfully encrypt and decrypt object content", async () => {
@@ -100,7 +130,11 @@ describe("encryptMetadata / decryptMetadata", () => {
describe("encryptImage / decryptImage", () => { describe("encryptImage / decryptImage", () => {
let masterKey: CryptoKey; let masterKey: CryptoKey;
beforeEach(async () => { 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 () => { it("should transform a File into an encrypted .bin Blob", async () => {
@@ -138,7 +172,8 @@ describe("encryptImage / decryptImage", () => {
describe("Sharing Key Decryption (TDD)", () => { describe("Sharing Key Decryption (TDD)", () => {
let masterKey: CryptoKey; let masterKey: CryptoKey;
beforeEach(async () => { 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 () => { it("should decrypt a letter using ONLY the sharing key", async () => {
+27 -7
View File
@@ -65,34 +65,54 @@ export class CryptoUtils {
}; };
/** /**
* Derives a Master Key from a password + email (salt). * Derives a Key Bundle (MasterKey + AuthHash) from a password + email.
* Same credentials = same key. * Absolute zero knowledge!!
*/ */
public static async deriveMasterKey( public static async deriveKeyBundle(
password: string, password: string,
email: string, email: string,
): Promise<CryptoKey> { ): Promise<{ masterKey: CryptoKey; authHash: string }> {
const enc = new TextEncoder(); const enc = new TextEncoder();
const salt = enc.encode(email.toLowerCase());
const baseKey = await crypto.subtle.importKey( const baseKey = await crypto.subtle.importKey(
"raw", "raw",
enc.encode(password), enc.encode(password),
"PBKDF2", "PBKDF2",
false, false,
["deriveKey"], ["deriveBits", "deriveKey"],
); );
return crypto.subtle.deriveKey( const masterSeed = await crypto.subtle.deriveBits(
{ {
name: "PBKDF2", name: "PBKDF2",
salt: enc.encode(email.toLowerCase()), salt,
iterations: CryptoUtils.PBKDF2_ITERATIONS, iterations: CryptoUtils.PBKDF2_ITERATIONS,
hash: "SHA-256", hash: "SHA-256",
}, },
baseKey, 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, CryptoUtils.AES_GCM,
false, false,
["encrypt", "decrypt", "wrapKey", "unwrapKey"], ["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 // Internal helper to encrypt data and wrap the key
+5 -4
View File
@@ -8,22 +8,23 @@ afterEach(async () => {
}); });
async function makeMasterKey() { async function makeMasterKey() {
return CryptoUtils.deriveMasterKey("test-password", "test@example.com"); return await CryptoUtils.deriveKeyBundle("test-password", "test@example.com");
} }
describe("keystore", () => { describe("keystore", () => {
it("should save and load a CryptoKey successfully", async () => { it("should save and load a CryptoKey successfully", async () => {
const key = await makeMasterKey(); const key = await makeMasterKey();
await saveMasterKey(key); await saveMasterKey(key.masterKey);
const keyfromMemory = await loadMasterKey(); const keyfromMemory = await loadMasterKey();
expect(keyfromMemory).toBeInstanceOf(CryptoKey); expect(keyfromMemory).toBeInstanceOf(CryptoKey);
expect(keyfromMemory).toEqual(key); expect(keyfromMemory).toEqual(key.masterKey);
}); });
it("should remove the stored key from memory", async () => { it("should remove the stored key from memory", async () => {
await saveMasterKey(await makeMasterKey()); const masterKey = await makeMasterKey();
await saveMasterKey(masterKey.masterKey);
await clearMasterKey(); await clearMasterKey();
const keyfromMemory = await loadMasterKey(); const keyfromMemory = await loadMasterKey();
+2 -1
View File
@@ -23,10 +23,11 @@ describe("letterLogic image helpers", () => {
let crypto: CryptoUtils; let crypto: CryptoUtils;
beforeEach(async () => { beforeEach(async () => {
masterKey = await CryptoUtils.deriveMasterKey( const keyBundle = await CryptoUtils.deriveKeyBundle(
"password123", "password123",
"test@example.com", "test@example.com",
); );
masterKey = keyBundle.masterKey;
crypto = new CryptoUtils(); crypto = new CryptoUtils();
await crypto.initialize(); await crypto.initialize();
vi.clearAllMocks(); vi.clearAllMocks();
+6
View File
@@ -0,0 +1,6 @@
export const mockMasterKey: CryptoKey = {
type: "secret",
algorithm: { name: "AES-GCM" },
extractable: false,
usages: ["encrypt", "decrypt"],
};
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
(podman compose up -d) &
(cd backend && uv run manage.py runserver) &
(cd frontend && bun run dev)