mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 00:56:34 +00:00
feat: enhance zero-knowledge authentication by deriving and sending auth hashes to the server
This commit is contained in:
@@ -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,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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -0,0 +1,6 @@
|
||||
export const mockMasterKey: CryptoKey = {
|
||||
type: "secret",
|
||||
algorithm: { name: "AES-GCM" },
|
||||
extractable: false,
|
||||
usages: ["encrypt", "decrypt"],
|
||||
};
|
||||
Reference in New Issue
Block a user