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 { 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();
+4 -9
View File
@@ -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,
};
+15 -3
View File
@@ -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<string | null>(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) {
+8 -1
View File
@@ -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) {
+55 -20
View File
@@ -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 () => {
+27 -7
View File
@@ -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<CryptoKey> {
): 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
+5 -4
View File
@@ -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();
+2 -1
View File
@@ -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();
+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)