Jotzy/frontend/src/api/encryption.tsx

150 lines
3.8 KiB
TypeScript
Raw Normal View History

2025-11-24 19:48:46 +00:00
import { FolderTreeResponse, FolderTreeNode } from "./folders";
export async function deriveKey(password: string, salt: string) {
2025-11-24 19:48:46 +00:00
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: enc.encode(salt),
2025-11-24 19:48:46 +00:00
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt", "wrapKey", "unwrapKey"],
);
}
export async function generateMasterKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
"encrypt",
"decrypt",
]);
}
export async function wrapMasterKey(
masterKey: CryptoKey,
kek: CryptoKey,
): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const wrapped = await crypto.subtle.wrapKey("raw", masterKey, kek, {
name: "AES-GCM",
iv,
});
const combined = new Uint8Array(iv.length + wrapped.byteLength);
combined.set(iv);
combined.set(new Uint8Array(wrapped), iv.length);
return btoa(String.fromCharCode(...combined));
}
export async function unwrapMasterKey(
wrappedKey: string,
kek: CryptoKey,
): Promise<CryptoKey> {
const combined = Uint8Array.from(atob(wrappedKey), (c) => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const wrapped = combined.slice(12);
return crypto.subtle.unwrapKey(
"raw",
wrapped,
kek,
{ name: "AES-GCM", iv },
{ name: "AES-GCM", length: 256 },
false,
2025-11-24 19:48:46 +00:00
["encrypt", "decrypt"],
);
}
export async function encryptString(
text: string,
key: CryptoKey,
): Promise<string> {
const enc = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
enc.encode(text),
);
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return btoa(String.fromCharCode(...combined));
}
export async function decryptString(encrypted: string, key: CryptoKey) {
const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const data = combined.slice(12);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
data,
);
return new TextDecoder().decode(decrypted);
}
export async function decryptFolderTree(
tree: FolderTreeResponse,
encryptionKey: CryptoKey,
): Promise<FolderTreeResponse> {
const decryptFolder = async (
folder: FolderTreeNode,
): Promise<FolderTreeNode> => {
return {
...folder,
notes: await Promise.all(
folder.notes.map(async (note) => ({
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
2025-11-24 19:48:46 +00:00
})),
),
children: await Promise.all(
folder.children.map((child) => decryptFolder(child)),
),
};
};
return {
folders: await Promise.all(
tree.folders.map((folder) => decryptFolder(folder)),
),
orphaned_notes: await Promise.all(
tree.orphaned_notes.map(async (note) => ({
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
2025-11-24 19:48:46 +00:00
})),
),
};
}