feat: implement master key derivation and persistence via IndexedDB

This commit is contained in:
Your Name
2026-04-12 01:24:28 +05:30
parent 9b9f1110ca
commit 7736fa5919
6 changed files with 267 additions and 221 deletions
+3
View File
@@ -16,6 +16,7 @@
"axios": "^1.15.0", "axios": "^1.15.0",
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"fabric": "^7.2.0", "fabric": "^7.2.0",
"idb": "^8.0.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.72.1", "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=="], "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=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+1
View File
@@ -23,6 +23,7 @@
"axios": "^1.15.0", "axios": "^1.15.0",
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"fabric": "^7.2.0", "fabric": "^7.2.0",
"idb": "^8.0.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.72.1", "react-hook-form": "^7.72.1",
+32 -7
View File
@@ -1,15 +1,35 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { api, publicApi } from "../api/apiClient"; import { api, publicApi } from "../api/apiClient";
import { endpoints } from "../config/endpoints"; 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 = () => { export const useAuth = () => {
const { accessToken, user, isInitializing, setAuth, clearAuth } = const { accessToken, user, isInitializing, setAuth, clearAuth } =
useAuthStore(); useAuthStore();
const { setMasterKey } = useKeyStore();
const isAuthenticated = !!accessToken; 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); setAuth(access, profile);
}; };
@@ -18,6 +38,8 @@ export const useAuth = () => {
await api.post(endpoints.LOGOUT); await api.post(endpoints.LOGOUT);
} finally { } finally {
clearAuth(); clearAuth();
setMasterKey(null);
await clearMasterKey();
} }
}; };
@@ -34,17 +56,20 @@ export const useAuth = () => {
try { try {
// try refresh // try refresh
const { data: refreshData } = await publicApi.post(endpoints.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, { const { data: userData } = await api.get(endpoints.ME, {
headers: { Authorization: `Bearer ${refreshData.access}` }, headers: { Authorization: `Bearer ${refreshData.access}` },
}); });
// update auth details in memory
setAuth(refreshData.access, userData); 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(); clearAuth();
setMasterKey(null);
await clearMasterKey();
} }
}, []); }, [setMasterKey]);
return { return {
isAuthenticated, isAuthenticated,
+1 -1
View File
@@ -42,7 +42,7 @@ export default function Login() {
headers: { Authorization: `Bearer ${authData.access}` }, headers: { Authorization: `Bearer ${authData.access}` },
}); });
login(authData.access, userData); login(authData.access, userData, data.password);
navigate(ROUTES.DRAWER); navigate(ROUTES.DRAWER);
} catch (err) { } catch (err) {
+60 -60
View File
@@ -11,18 +11,36 @@ export interface EncryptedLetter {
export interface EncryptedImageUpload { export interface EncryptedImageUpload {
encryptedBlob: Blob; encryptedBlob: Blob;
encrypted_dek: string; encrypted_dek: string;
filename: string;
}
/*
* Wrapper functions
*/
interface SealedEnvelope {
encryptedContent: string;
encrypted_dek: string;
sharingKey: string; sharingKey: string;
} }
const PBKDF2_ITERATIONS = 100_000; export class CryptoUtils {
const AES_GCM = { name: "AES-GCM", length: 256 } as const; private dek: CryptoKey = {} as CryptoKey;
private static readonly PBKDF2_ITERATIONS = 100_000;
private static readonly AES_GCM = { name: "AES-GCM", length: 256 };
async initialize() {
this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_GCM, true, [
"encrypt",
"decrypt",
]);
}
// base64 conversion for transit // base64 conversion for transit
const toBase64 = (buf: Uint8Array): string => toBase64 = (buf: Uint8Array): string =>
btoa(buf.reduce((s, b) => s + String.fromCharCode(b), "")); btoa(buf.reduce((s, b) => s + String.fromCharCode(b), ""));
// explicit loop ensures Uint8Array<ArrayBuffer> (not ArrayBufferLike) // explicit loop ensures Uint8Array<ArrayBuffer> (not ArrayBufferLike)
const fromBase64 = (b64: string): Uint8Array<ArrayBuffer> => { fromBase64 = (b64: string): Uint8Array<ArrayBuffer> => {
const str = atob(b64); const str = atob(b64);
const arr = new Uint8Array(str.length); const arr = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) arr[i] = str.charCodeAt(i); for (let i = 0; i < str.length; i++) arr[i] = str.charCodeAt(i);
@@ -30,18 +48,18 @@ const fromBase64 = (b64: string): Uint8Array<ArrayBuffer> => {
}; };
// bundle IV + data into a single base64 string // bundle IV + data into a single base64 string
const packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => { packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => {
const packed = new Uint8Array(iv.length + data.byteLength); const packed = new Uint8Array(iv.length + data.byteLength);
packed.set(iv); packed.set(iv);
packed.set(new Uint8Array(data), iv.length); packed.set(new Uint8Array(data), iv.length);
return toBase64(packed); return this.toBase64(packed);
}; };
// split IV (first 12 bytes) back out from a packed base64 bundle // split IV (first 12 bytes) back out from a packed base64 bundle
const unpackWithIv = ( unpackWithIv = (
b64: string, b64: string,
): [Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>] => { ): [Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>] => {
const buf = fromBase64(b64); // ArrayBuffer-backed, so buf.buffer is ArrayBuffer 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 [new Uint8Array(buf.buffer, 0, 12), new Uint8Array(buf.buffer, 12)];
}; };
@@ -49,7 +67,7 @@ const unpackWithIv = (
* Derives a Master Key from a password and email (salt). * Derives a Master Key from a password and email (salt).
* Deterministic — same credentials always produce the same key. * Deterministic — same credentials always produce the same key.
*/ */
export async function deriveMasterKey( public static async deriveMasterKey(
password: string, password: string,
email: string, email: string,
): Promise<CryptoKey> { ): Promise<CryptoKey> {
@@ -67,82 +85,67 @@ export async function deriveMasterKey(
{ {
name: "PBKDF2", name: "PBKDF2",
salt: enc.encode(email.toLowerCase()), salt: enc.encode(email.toLowerCase()),
iterations: PBKDF2_ITERATIONS, iterations: CryptoUtils.PBKDF2_ITERATIONS,
hash: "SHA-256", hash: "SHA-256",
}, },
baseKey, baseKey,
AES_GCM, CryptoUtils.AES_GCM,
false, false,
["encrypt", "decrypt", "wrapKey", "unwrapKey"], ["encrypt", "decrypt", "wrapKey", "unwrapKey"],
); );
} }
/* private async sealEnvelope(
* Wrapper functions
*/
interface SealedEnvelope {
encryptedContent: string;
encrypted_dek: string;
sharingKey: string;
}
async function sealEnvelope(
input: Uint8Array, input: Uint8Array,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<SealedEnvelope> { ): Promise<SealedEnvelope> {
// copy into a fresh ArrayBuffer — WebCrypto requires ArrayBuffer-backed arrays // copy into a fresh ArrayBuffer — WebCrypto requires ArrayBuffer-backed arrays
const plainBytes = new Uint8Array(input); const plainBytes = new Uint8Array(input);
// 1-time DEK for this payload
const dek = await crypto.subtle.generateKey(AES_GCM, true, [
"encrypt",
"decrypt",
]);
// encrypt the content with the DEK // encrypt the content with the DEK
const contentIv = crypto.getRandomValues(new Uint8Array(12)); const contentIv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt( const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: contentIv }, { name: "AES-GCM", iv: contentIv },
dek, this.dek,
plainBytes, plainBytes,
); );
// wrap the DEK with the Master Key (for self/owner access) // wrap the DEK with the Master Key (for self/owner access)
const dekIv = crypto.getRandomValues(new Uint8Array(12)); const dekIv = crypto.getRandomValues(new Uint8Array(12));
const wrappedDek = await crypto.subtle.wrapKey("raw", dek, masterKey, { const wrappedDek = await crypto.subtle.wrapKey("raw", this.dek, masterKey, {
name: "AES-GCM", name: "AES-GCM",
iv: dekIv, iv: dekIv,
}); });
// export raw DEK for the share URL (recipient access, no master key needed) // export raw DEK for the share URL (recipient access, no master key needed)
const rawDek = await crypto.subtle.exportKey("raw", dek); const rawDek = await crypto.subtle.exportKey("raw", this.dek);
return { return {
encryptedContent: packWithIv(contentIv, ciphertext), encryptedContent: this.packWithIv(contentIv, ciphertext),
encrypted_dek: packWithIv(dekIv, wrappedDek), encrypted_dek: this.packWithIv(dekIv, wrappedDek),
sharingKey: toBase64(new Uint8Array(rawDek)), sharingKey: this.toBase64(new Uint8Array(rawDek)),
}; };
} }
async function openEnvelope( private async openEnvelope(
encryptedContent: string, encryptedContent: string,
encrypted_dek: string, encrypted_dek: string,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<Uint8Array<ArrayBuffer>> { ): Promise<Uint8Array<ArrayBuffer>> {
// unwrap the DEK using the master key // unwrap the DEK using the master key
const [dekIv, wrappedDek] = unpackWithIv(encrypted_dek); const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek);
const dek = await crypto.subtle.unwrapKey( const dek = await crypto.subtle.unwrapKey(
"raw", "raw",
wrappedDek, wrappedDek,
masterKey, masterKey,
{ name: "AES-GCM", iv: dekIv }, { name: "AES-GCM", iv: dekIv },
AES_GCM, CryptoUtils.AES_GCM,
false, false,
["decrypt"], ["decrypt"],
); );
// decrypt the content with the recovered DEK // decrypt the content with the recovered DEK
const [contentIv, ciphertext] = unpackWithIv(encryptedContent); const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent);
const plainBytes = await crypto.subtle.decrypt( const plainBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: contentIv }, { name: "AES-GCM", iv: contentIv },
dek, dek,
@@ -155,23 +158,21 @@ async function openEnvelope(
/* /*
* Letter functions * Letter functions
*/ */
export async function encryptLetter( public async encryptLetter(
plaintext: string, plaintext: string,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<EncryptedLetter> { ): Promise<EncryptedLetter> {
const { encryptedContent, encrypted_dek, sharingKey } = await sealEnvelope( const { encryptedContent, encrypted_dek, sharingKey } =
new TextEncoder().encode(plaintext), await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey);
masterKey,
);
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
} }
export async function decryptLetter( public async decryptLetter(
{ encrypted_content, encrypted_dek }: EncryptedLetter, { encrypted_content, encrypted_dek }: EncryptedLetter,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<string> { ): Promise<string> {
const plainBytes = await openEnvelope( const plainBytes = await this.openEnvelope(
encrypted_content, encrypted_content,
encrypted_dek, encrypted_dek,
masterKey, masterKey,
@@ -182,11 +183,12 @@ export async function decryptLetter(
/* /*
* Metadata functions * Metadata functions
*/ */
export async function encryptMetadata( public async encryptMetadata(
metadata: Record<string, string>, metadata: Record<string, string>,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<EncryptedLetter> { ): Promise<EncryptedLetter> {
const { encryptedContent, encrypted_dek, sharingKey } = await sealEnvelope( const { encryptedContent, encrypted_dek, sharingKey } =
await this.sealEnvelope(
new TextEncoder().encode(JSON.stringify(metadata)), new TextEncoder().encode(JSON.stringify(metadata)),
masterKey, masterKey,
); );
@@ -194,11 +196,11 @@ export async function encryptMetadata(
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
} }
export async function decryptMetadata( public async decryptMetadata(
encrypted_metadata: EncryptedLetter, encrypted_metadata: EncryptedLetter,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
const plainBytes = await openEnvelope( const plainBytes = await this.openEnvelope(
encrypted_metadata.encrypted_content, encrypted_metadata.encrypted_content,
encrypted_metadata.encrypted_dek, encrypted_metadata.encrypted_dek,
masterKey, masterKey,
@@ -209,34 +211,31 @@ export async function decryptMetadata(
/* /*
* Image functions * Image functions
*/ */
export async function encryptImage( public async encryptImage(
file: File, file: File,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<EncryptedImageUpload> { ): Promise<EncryptedImageUpload> {
const plainBytes = new Uint8Array(await file.arrayBuffer()); const plainBytes = new Uint8Array(await file.arrayBuffer());
const { encryptedContent, encrypted_dek, sharingKey } = await sealEnvelope( const { encryptedContent, encrypted_dek } = await this.sealEnvelope(
plainBytes, plainBytes,
masterKey, masterKey,
); );
return { return {
encryptedBlob: new Blob([fromBase64(encryptedContent)]), encryptedBlob: new Blob([this.fromBase64(encryptedContent)]),
encrypted_dek, encrypted_dek,
sharingKey, filename: `${crypto.randomUUID()}.bin`,
}; };
} }
export async function decryptImage( public async decryptImage(
encryptedUrl: string, encryptedBlob: Blob,
encrypted_dek: string, encrypted_dek: string,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<string> { ): Promise<string> {
// fetch encrypted bytes from server and repack as base64 for openEnvelope const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
const encryptedBytes = new Uint8Array( const plainBytes = await this.openEnvelope(
await (await fetch(encryptedUrl)).arrayBuffer(), this.toBase64(encryptedBytes),
);
const plainBytes = await openEnvelope(
toBase64(encryptedBytes),
encrypted_dek, encrypted_dek,
masterKey, masterKey,
); );
@@ -244,3 +243,4 @@ export async function decryptImage(
// return as object URL for use in Fabric / <img> // return as object URL for use in Fabric / <img>
return URL.createObjectURL(new Blob([plainBytes])); return URL.createObjectURL(new Blob([plainBytes]));
} }
}
+17
View File
@@ -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<CryptoKey | null> =>
(await db).get("master-key", "masterKey") ?? null;
export const clearMasterKey = async () =>
(await db).delete("master-key", "masterKey");