diff --git a/frontend/repomix-output.xml b/frontend/repomix-output.xml new file mode 100644 index 0000000..e9e185b --- /dev/null +++ b/frontend/repomix-output.xml @@ -0,0 +1,1358 @@ +This file is a merged representation of a subset of the codebase, containing specifically included files, combined into a single document by Repomix. + + +This section contains a summary of this file. + + +This file contains a packed representation of a subset of the repository's contents that is considered the most important context. +It is designed to be easily consumable by AI systems for analysis, code review, +or other automated processes. + + + +The content is organized as follows: +1. This summary section +2. Repository information +3. Directory structure +4. Repository files (if enabled) +5. Multiple file entries, each consisting of: + - File path as an attribute + - Full contents of the file + + + +- This file should be treated as read-only. Any changes should be made to the + original repository files, not this packed version. +- When processing this file, use the file path to distinguish + between different files in the repository. +- Be aware that this file may contain sensitive information. Handle it with + the same level of security as you would the original repository. + + + +- Some files may have been excluded based on .gitignore rules and Repomix's configuration +- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files +- Only files matching these patterns are included: **/*.ts, **/*.tsx +- Files matching patterns in .gitignore are excluded +- Files matching default ignore patterns are excluded +- Files are sorted by Git change count (files with more changes are at the bottom) + + + + + +src/ + api/ + apiClient.ts + components/ + ui/ + ComposeCanvas.tsx + DateDisplay.tsx + FormField.tsx + Logo.tsx + RouteGuards.tsx + SplashScreen.tsx + config/ + endpoints.ts + routes.ts + hooks/ + useAuth.ts + pages/ + Activate.tsx + Drawer.tsx + Editor.tsx + Home.tsx + Login.tsx + Register.tsx + VerifyEmail.tsx + store/ + useAuthStore.ts + utils/ + crypto.ts + App.tsx + main.tsx +vite.config.ts + + + +This section contains the contents of the repository's files. + + +import axios from "axios"; +import { endpoints } from "../config/endpoints"; +import { useAuthStore } from "../store/useAuthStore"; + +// publicApi for endpoints that don't need authentication (login, refresh, register) +export const publicApi = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + withCredentials: true, +}); + +// api for all authenticated requests +export const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + withCredentials: true, +}); + +// auto-attach access token to authenticated requests +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Handle 401 errors by attempting a silent refresh +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + // If 401 and we haven't tried refreshing yet + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + // Attempt silent refresh + const { data } = await publicApi.post(endpoints.REFRESH); + const newAccessToken = data.access; + + // Update store + const { user, setAuth } = useAuthStore.getState(); + if (user) { + setAuth(newAccessToken, user); + } + + // Retry the original request with the new token + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return api(originalRequest); + } catch (refreshError) { + // Refresh failed, perform logout to clear tokens + console.error("Session expired, logging out..."); + useAuthStore.getState().clearAuth(); + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + }, +); + + + +import * as fabric from "fabric"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; + +const PAD = 36; + +export type CanvasTools = { + addImage: (url: string) => void; +}; + +export const ComposeCanvas = forwardRef((_props, ref) => { + const wrapperRef = useRef(null); + const canvasRef = useRef(null); + const fabricRef = useRef(null); + const textboxRef = useRef(null); + + useEffect(() => { + let isMounted = true; + let canvas: fabric.Canvas | null = null; + + const init = async () => { + // lazy populate + await document.fonts.ready; + const waitForLayout = (): Promise => { + return new Promise((resolve) => { + const check = () => { + const wrapperWidth = wrapperRef.current?.clientWidth || 0; + if (wrapperWidth > 0) resolve(wrapperWidth); + else requestAnimationFrame(check); + }; + check(); + }); + }; + + const finalWidth = await waitForLayout(); + if (!isMounted || !canvasRef.current || !wrapperRef.current) return; + + const initialHeight = Math.max( + wrapperRef.current.clientHeight || 900, + 600, + ); + + // init canvas + canvas = new fabric.Canvas(canvasRef.current, { + width: finalWidth, + height: initialHeight, + selection: false, + preserveObjectStacking: true, + allowTouchScrolling: true, // for mobile + }); + + fabricRef.current = canvas; + + // transparent background + const wrapperEl = canvas.getElement().parentElement; + if (wrapperEl) wrapperEl.style.background = "transparent"; + + // the core textbox + const textbox = new fabric.Textbox("Take a deep breath...", { + originX: "left", + originY: "top", + left: PAD, + top: PAD, + width: finalWidth - PAD * 2, + fontSize: 16, + fontWeight: 500, + fontFamily: "Playfair Display Variable", + fill: "#000", + lineHeight: 1.5, + editable: true, + hasControls: false, + hasBorders: false, + objectCaching: false, // for font crispness + splitByGrapheme: false, + lockMovementX: true, + lockMovementY: true, + lockScalingX: true, + lockScalingY: true, + }); + + textboxRef.current = textbox; + canvas.add(textbox); + + // automatically adjust height + textbox.on("changed", () => { + if (!canvas || !wrapperRef.current) return; + const neededHeight = textbox.top + textbox.height + PAD; + if (neededHeight > canvas.height) { + const newH = neededHeight + PAD; + canvas.setDimensions({ height: newH }); + wrapperRef.current.style.height = `${newH}px`; + } + }); + + // auto focus + setTimeout(() => { + if (!isMounted) return; + canvas?.setActiveObject(textbox); + textbox.enterEditing(); + canvas?.renderAll(); + + // Accessibility fix for Fabric.js hidden textarea + // searching globally in case it is appended to body + const hiddenTextareas = document.querySelectorAll( + 'textarea[data-fabric="textarea"]', + ); + hiddenTextareas.forEach((ta) => { + if (!ta.getAttribute("aria-label")) { + ta.setAttribute("aria-label", "Canvas text input"); + } + }); + }, 100); + + canvas.on("mouse:down", (opt) => { + if (!opt.target || opt.target === textbox) { + canvas?.setActiveObject(textbox); + textbox.enterEditing(); + canvas?.renderAll(); + } + }); + }; + + init(); + + return () => { + isMounted = false; + canvas?.dispose(); + fabricRef.current = null; + textboxRef.current = null; + }; + }, []); + + useImperativeHandle(ref, () => ({ + addImage: (url: string) => { + if (!fabricRef.current) return; + fabric.FabricImage.fromURL(url).then((img) => { + img.scaleToWidth(300); + img.set({ + left: PAD, + top: PAD, + }); + fabricRef.current?.add(img); + fabricRef.current?.setActiveObject(img); + fabricRef.current?.requestRenderAll(); + + URL.revokeObjectURL(url); // cleanup browser upload + }); + }, + })); + + return ( +
+ +
+ ); +}); +ComposeCanvas.displayName = "ComposeCanvas"; +
+ + +interface DateDisplayProps { + date?: Date; + className?: string; +} + +export default function DateDisplay({ + date = new Date(), + className = "", +}: DateDisplayProps) { + const formattedDate = date.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }); + + return ( +
+ + Date + + + {formattedDate} + +
+ ); +} +
+ + +import type { UseFormRegisterReturn } from "react-hook-form"; + +interface FormFieldProps { + label: string; + type?: string; + placeholder?: string; + registration: UseFormRegisterReturn; + error?: string; +} + +export default function FormField({ + label, + type = "text", + placeholder, + registration, + error, +}: FormFieldProps) { + return ( +
+ + + {error &&

{error}

} +
+ ); +} +
+ + +import { DotIcon } from "@phosphor-icons/react"; +import "@fontsource/knewave/400.css"; + +export default function Logo() { + return ( + + Pi + + Ku + + + ); +} + + + +import { Navigate, useLocation } from "react-router-dom"; +import { ROUTES } from "../config/routes"; +import { useAuth } from "../hooks/useAuth"; +import SplashScreen from "./SplashScreen"; + +/** + * Post-login routes. + * Redirects to /login if not already authenticated. + */ +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isInitializing } = useAuth(); + const location = useLocation(); + + if (isInitializing) return ; + + if (!isAuthenticated) { + // Save the intended location to redirect back after login + return ; + } + + return <>{children}; +} + +/** + * Pre-login flows. + * Redirects to /drawer if already authenticated. + */ +export function PublicRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isInitializing } = useAuth(); + + if (isInitializing) return ; + + if (isAuthenticated) { + return ; + } + + return <>{children}; +} + + + +import Logo from "./Logo"; + +export default function SplashScreen() { + return ( +
+
+ +
+ +

+ Initializing Identity +

+
+
+
+ ); +} +
+ + +export const endpoints = { + LOGIN: "/api/auth/login/", + REGISTER: "/api/auth/register/", + VERIFY_EMAIL: "/api/auth/verify-email/", + ACTIVATE: "/api/auth/activate/:uidb64/:token/", + ME: "/api/auth/me/", + REFRESH: "/api/auth/refresh/", + LOGOUT: "/api/auth/logout/", +}; + +// simple utility to handle path params +export const replacePathParams = ( + url: string, + params: Record, +): string => { + let result = url; + Object.entries(params).forEach(([key, value]) => { + result = result.replace(`:${key}`, value); + }); + return result; +}; + + + +export const ROUTES = { + HOME: "/", + ONBOARD: "/onboard", + VERIFY_EMAIL: "/verify-email", + ACTIVATE: "/activate/:uidb64/:token", + LOGIN: "/login", + DRAWER: "/drawer", + WRITE: "/quill", + READ: "/read", +}; + + + +import { useCallback } from "react"; +import { api, publicApi } from "../api/apiClient"; +import { endpoints } from "../config/endpoints"; +import { type UserProfile, useAuthStore } from "../store/useAuthStore"; + +export const useAuth = () => { + const { accessToken, user, isInitializing, setAuth, clearAuth } = + useAuthStore(); + + const isAuthenticated = !!accessToken; + + const login = (access: string, profile: UserProfile) => { + setAuth(access, profile); + }; + + const logout = async () => { + try { + await api.post(endpoints.LOGOUT); + } finally { + clearAuth(); + } + }; + + const initialize = useCallback(async () => { + const { accessToken, user, setAuth, clearAuth, setInitializing } = + useAuthStore.getState(); + + // If session in memory, don't trigger refresh/me again + if (accessToken && user) { + setInitializing(false); + return; + } + + try { + // try 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, { + headers: { Authorization: `Bearer ${refreshData.access}` }, + }); + // update auth details in memory + setAuth(refreshData.access, userData); + } catch (err) { + console.error("Initialization failed:", err); + clearAuth(); + } + }, []); + + return { + isAuthenticated, + user, + isInitializing, + login, + logout, + initialize, + }; +}; + + + +import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { publicApi } from "../api/apiClient"; +import Logo from "../components/Logo"; +import { endpoints, replacePathParams } from "../config/endpoints"; +import { ROUTES } from "../config/routes"; + +export default function Activate() { + const { uidb64, token } = useParams(); + const [status, setStatus] = useState<"loading" | "success" | "error">( + "loading", + ); + const hasCalled = useRef(false); + const navigate = useNavigate(); + + useEffect(() => { + if (!uidb64 || !token || hasCalled.current) return; + + // prevent double api calls + hasCalled.current = true; + + const activateAccount = async () => { + try { + const url = replacePathParams(endpoints.ACTIVATE, { + uidb64, + token, + }); + await publicApi.get(url); + setStatus("success"); + } catch (err) { + console.error("Activation error:", err); + setStatus("error"); + } + }; + + activateAccount(); + }, [uidb64, token]); + + return ( +
+ {status === "loading" && ( +
+ +

Activating your account...

+
+ )} + + {status === "success" && ( +
+
+ +
+

+ Account Activated! +

+

+ Welcome to +
+ Your identity is now verified and ready for timeless letters. +

+ +
+ )} + + {status === "error" && ( +
+
+ +
+

Activation Failed

+

+ The link might be expired or already used. Please try registering + again. +

+ +
+ )} +
+ ); +} +
+ + +import { useAuth } from "../hooks/useAuth"; + +export default function Drawer() { + const { user, logout } = useAuth(); + + if (!user) return null; + + return ( +
+
+

+ Your Drawer +

+

Welcome back, {user.full_name}

+
+ +
+ + +
+ ); +} + + + +import { ImageIcon, LockIcon, TrayIcon } from "@phosphor-icons/react"; +import { useRef, useState } from "react"; +import { + type CanvasTools, + ComposeCanvas, +} from "../components/ui/ComposeCanvas"; +import DateDisplay from "../components/ui/DateDisplay"; + +export default function Editor() { + const [recipient, setRecipient] = useState(""); + + const canvasRef = useRef(null); + const fileInputRef = useRef(null); + const handleImageUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; // pick one file at a time + if (file) { + const url = URL.createObjectURL(file); + canvasRef.current?.addImage(url); + } + }; + + return ( +
+
+
+
+ + setRecipient(e.target.value)} + className="bg-transparent border-none outline-none text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full" + /> +
+ +
+ +
+
+ + +
+ +
+ + +
+ + +
+
+ +
+
+ ); +} +
+ + +import Logo from "../components/Logo"; + +export default function Home() { + return ( +
+ +
+ ); +} +
+ + +import { zodResolver } from "@hookform/resolvers/zod"; +import axios from "axios"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { z } from "zod"; +import { api, publicApi } from "../api/apiClient"; +import Logo from "../components/Logo"; +import FormField from "../components/ui/FormField"; +import { endpoints } from "../config/endpoints"; +import { ROUTES } from "../config/routes"; +import { useAuth } from "../hooks/useAuth"; + +const loginSchema = z.object({ + email: z.string().email("Please enter a valid email"), + password: z.string().min(1, "Password is required"), +}); + +type LoginInputs = z.infer; + +export default function Login() { + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [apiError, setApiError] = useState(null); + const { login } = useAuth(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = async (data: LoginInputs) => { + setIsLoading(true); + setApiError(null); + try { + // 1. Authenticate + const { data: authData } = await publicApi.post(endpoints.LOGIN, data); + + // 2. Fetch User Profile with the fresh token + // We pass the header explicitly to avoid any race conditions with interceptors + const { data: userData } = await api.get(endpoints.ME, { + headers: { Authorization: `Bearer ${authData.access}` }, + }); + + // 3. Update store using the hook method + await login(authData.access, userData); + + navigate(ROUTES.DRAWER); + } catch (err) { + console.error("Login error:", err); + let message = "Invalid email or password"; + if (axios.isAxiosError(err)) { + message = + err.response?.data?.detail || err.response?.data?.message || message; + } + setApiError(message); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

+ Sign in to +

+ + {apiError && ( +
+ {apiError} +
+ )} + + + + + +
+ +
+ +
+ Don't have an account?{" "} + +
+ +
+ ); +} +
+ + +import { zodResolver } from "@hookform/resolvers/zod"; +import { InfoIcon } from "@phosphor-icons/react"; +import axios from "axios"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { z } from "zod"; +import { publicApi } from "../api/apiClient"; +import Logo from "../components/Logo"; +import FormField from "../components/ui/FormField"; +import { endpoints } from "../config/endpoints"; +import { ROUTES } from "../config/routes"; + +// validation logic +const registerSchema = z + .object({ + full_name: z.string().min(2, "Name must be at least 2 characters"), + email: z.email("Please enter a valid email"), + password: z.string().min(8, "Password must be at least 8 characters"), + confirm_password: z.string(), + }) + .refine((data) => data.password === data.confirm_password, { + message: "Passwords don't match", + path: ["confirm_password"], + }); + +type RegisterInputs = z.infer; + +export default function Register() { + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [apiError, setApiError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(registerSchema), + }); + + const onSubmit = async (data: RegisterInputs) => { + setIsLoading(true); + setApiError(null); + try { + await publicApi.post(endpoints.REGISTER, { + full_name: data.full_name, + email: data.email, + password: data.password, + }); + navigate(ROUTES.VERIFY_EMAIL); + } catch (err) { + console.error("Registration error:", err); + let message = "Registration failed. Please try again."; + if (axios.isAxiosError(err)) { + message = err.response?.data?.message || message; + } + setApiError(message); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

+ Create a Account +

+ + {apiError && ( +
+ {apiError} +
+ )} + + + + + + + + + + {/* Warning */} +
+ +

+ Choose a password you won't forget.
+ There is no reset.{" "} + If you lose it, your letters cannot be recovered. +

+
+ +
+ +
+ +
+ ); +} +
+ + +import { EnvelopeSimpleOpenIcon } from "@phosphor-icons/react"; +import Logo from "../components/Logo"; + +export default function VerifyEmail() { + return ( +
+
+ +
+ +
+

Check Your Email

+

+ We've sent an activation link to your inbox.
+ Please click it to verify your account. +

+
+ +
+ +
+

+ Didn't receive it? Check your spam folder or wait for a few minutes. + The link will expire in 24 hours. +

+
+ + +
+ ); +} +
+ + +import { create } from "zustand"; + +export interface UserProfile { + public_id: string; + email: string; + full_name: string; +} + +interface AuthState { + accessToken: string | null; + user: UserProfile | null; + isInitializing: boolean; + setAuth: (accessToken: string, user: UserProfile) => void; + clearAuth: () => void; + setInitializing: (v: boolean) => void; +} + +export const useAuthStore = create((set) => ({ + accessToken: null, + user: null, + isInitializing: true, + setAuth: (accessToken, user) => + set({ accessToken, user, isInitializing: false }), + clearAuth: () => + set({ accessToken: null, user: null, isInitializing: false }), + setInitializing: (isInitializing) => set({ isInitializing }), +})); + + + +/** + * 0 knowledge cryptography. No Server involved in encryption/decryption + */ + +const ITERATIONS = 100000; +const KEY_ALGO = { name: "AES-GCM", length: 256 }; + +/** + * Derives a Master Encryption Key from a password and email (salt). + */ +export async function deriveMasterKey( + password: string, + email: string, +): Promise { + const encoder = new TextEncoder(); + const passwordKey = await crypto.subtle.importKey( + "raw", + encoder.encode(password), + "PBKDF2", + false, + ["deriveKey"], + ); + + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: encoder.encode(email.toLowerCase()), + iterations: ITERATIONS, + hash: "SHA-256", + }, + passwordKey, + KEY_ALGO, + false, + ["encrypt", "decrypt", "wrapKey", "unwrapKey"], + ); +} + +/** + * Encrypts a letter using Envelope Encryption. + */ +export async function encryptLetter(plaintext: string, masterKey: CryptoKey) { + const encoder = new TextEncoder(); + + // Generate random Data Encryption Key (DEK) + const dek = await crypto.subtle.generateKey(KEY_ALGO, true, [ + "encrypt", + "decrypt", + ]); + + // Encrypt the content with the DEK + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + dek, + encoder.encode(plaintext), + ); + + // encrpyt the DEK using the Master Key for the self access + const keyIv = crypto.getRandomValues(new Uint8Array(12)); + const wrappedKey = await crypto.subtle.wrapKey("raw", dek, masterKey, { + name: "AES-GCM", + iv: keyIv, + }); + + // for recipients (link share), export DEK in raw format + const rawKey = await crypto.subtle.exportKey("raw", dek); + + // conversion to base64 for transit + const toBase64 = (buf: Uint8Array) => + btoa(buf.reduce((acc, b) => acc + String.fromCharCode(b), "")); + + return { + // This goes to the server + encryptedPayload: { + ciphertext: toBase64(new Uint8Array(ciphertext)), + iv: toBase64(new Uint8Array(iv)), + wrappedKey: toBase64(new Uint8Array(wrappedKey)), + keyIv: toBase64(new Uint8Array(keyIv)), + }, + // This goes into the url for the recipient + sharingKey: toBase64(new Uint8Array(rawKey)), + }; +} + + + +import { useEffect } from "react"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { ProtectedRoute, PublicRoute } from "./components/RouteGuards"; +import SplashScreen from "./components/SplashScreen"; +import { ROUTES } from "./config/routes"; +import { useAuth } from "./hooks/useAuth"; +import Activate from "./pages/Activate"; +import Drawer from "./pages/Drawer"; +import Editor from "./pages/Editor"; +// Pages +import Home from "./pages/Home"; +import Login from "./pages/Login"; +import Register from "./pages/Register"; +import VerifyEmail from "./pages/VerifyEmail"; + +export default function App() { + const { initialize, isInitializing } = useAuth(); + + useEffect(() => { + initialize(); + }, [initialize]); + + if (isInitializing) { + return ; + } + + return ( + +
+ + } /> + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + } + /> + + + + } + /> + } /> + +
+
+ ); +} +
+ + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import "@fontsource-variable/playwrite-hr-lijeva/wght.css"; +import "@fontsource-variable/jost/wght.css"; +import "@fontsource-variable/playfair-display/wght.css"; +import App from "./App.tsx"; + +const root = document.getElementById("root"); +if (root) { + createRoot(root).render( + + + , + ); +} + + + +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig, loadEnv } from "vite"; + +// https://vite.dev/config/ +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, "../", ""); + return { + envDir: "../", + plugins: [react(), tailwindcss()], + server: { + port: Number(env.FRONTEND_PORT), + host: env.FRONTEND_DOMAIN, + }, + } +}); + + + diff --git a/frontend/src/store/useKeyStore.ts b/frontend/src/store/useKeyStore.ts new file mode 100644 index 0000000..c519205 --- /dev/null +++ b/frontend/src/store/useKeyStore.ts @@ -0,0 +1,12 @@ +import { create } from "zustand"; + +interface KeyState { + masterKey: CryptoKey | null; + setMasterKey: (key: CryptoKey | null) => void; +} + +// this key will be used to encrypt and decrypt the user's data +export const useKeyStore = create((set) => ({ + masterKey: null, + setMasterKey: (masterKey) => set({ masterKey }), +})); diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts index dc66b80..995de71 100644 --- a/frontend/src/utils/crypto.ts +++ b/frontend/src/utils/crypto.ts @@ -1,21 +1,42 @@ /** - * 0 knowledge cryptography. No Server involved in encryption/decryption + * 0 knowledge cryptography — no server involvement in encryption/decryption. */ -const ITERATIONS = 100000; -const KEY_ALGO = { name: "AES-GCM", length: 256 }; +// IV is the Initialization Vector - random value to randomize the encryption output +// DEK is the Data Encryption Key - random value to encrypt the plaintext +export interface EncryptedLetter { + encrypted_content: string; // IV + ciphertext, base64 + encrypted_dek: string; // IV + wrapped DEK, base64 + sharingKey: string; // raw DEK, base64 (embedded in share URL) +} + +const PBKDF2_ITERATIONS = 100_000; +const AES_GCM = { name: "AES-GCM", length: 256 } as const; + +const toBase64 = (buf: Uint8Array): string => + btoa(buf.reduce((s, b) => s + String.fromCharCode(b), "")); + +// Prefix the IV to data and base64-encode the result. +const packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => { + const packed = new Uint8Array(iv.length + data.byteLength); + packed.set(iv); + packed.set(new Uint8Array(data), iv.length); + return toBase64(packed); +}; /** - * Derives a Master Encryption Key from a password and email (salt). + * Derives a Master Key from the user's password and email (used as PBKDF2 salt). + * Note: it is deterministic, i.e. the same credentials always produce the same key */ export async function deriveMasterKey( password: string, email: string, ): Promise { - const encoder = new TextEncoder(); - const passwordKey = await crypto.subtle.importKey( + const enc = new TextEncoder(); + + const baseKey = await crypto.subtle.importKey( "raw", - encoder.encode(password), + enc.encode(password), "PBKDF2", false, ["deriveKey"], @@ -24,12 +45,12 @@ export async function deriveMasterKey( return crypto.subtle.deriveKey( { name: "PBKDF2", - salt: encoder.encode(email.toLowerCase()), - iterations: ITERATIONS, + salt: enc.encode(email.toLowerCase()), + iterations: PBKDF2_ITERATIONS, hash: "SHA-256", }, - passwordKey, - KEY_ALGO, + baseKey, + AES_GCM, false, ["encrypt", "decrypt", "wrapKey", "unwrapKey"], ); @@ -37,47 +58,44 @@ export async function deriveMasterKey( /** * Encrypts a letter using Envelope Encryption. + * + * plaintext >> DEK >> encrypted_content + * DEK >> masterKey >> encrypted_dek + * DEK >> raw >> sharingKey */ -export async function encryptLetter(plaintext: string, masterKey: CryptoKey) { - const encoder = new TextEncoder(); +export async function encryptLetter( + plaintext: string, + masterKey: CryptoKey, +): Promise { + const enc = new TextEncoder(); - // Generate random Data Encryption Key (DEK) - const dek = await crypto.subtle.generateKey(KEY_ALGO, true, [ + // 1time DEK for this letter + const dek = await crypto.subtle.generateKey(AES_GCM, true, [ "encrypt", "decrypt", ]); - // Encrypt the content with the DEK - const iv = crypto.getRandomValues(new Uint8Array(12)); + // encrypt the plaintext with the DEK + const contentIv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, + { name: "AES-GCM", iv: contentIv }, dek, - encoder.encode(plaintext), + enc.encode(plaintext), ); - // encrpyt the DEK using the Master Key for the self access - const keyIv = crypto.getRandomValues(new Uint8Array(12)); - const wrappedKey = await crypto.subtle.wrapKey("raw", dek, masterKey, { + // wrap the DEK with the Master Key (for self access) + const dekIv = crypto.getRandomValues(new Uint8Array(12)); + const wrappedDek = await crypto.subtle.wrapKey("raw", dek, masterKey, { name: "AES-GCM", - iv: keyIv, + iv: dekIv, }); - // for recipients (link share), export DEK in raw format - const rawKey = await crypto.subtle.exportKey("raw", dek); - - // conversion to base64 for transit - const toBase64 = (buf: Uint8Array) => - btoa(buf.reduce((acc, b) => acc + String.fromCharCode(b), "")); + // export raw DEK for the share URL (recipient access, no master key needed) + const rawDek = await crypto.subtle.exportKey("raw", dek); return { - // This goes to the server - encryptedPayload: { - ciphertext: toBase64(new Uint8Array(ciphertext)), - iv: toBase64(new Uint8Array(iv)), - wrappedKey: toBase64(new Uint8Array(wrappedKey)), - keyIv: toBase64(new Uint8Array(keyIv)), - }, - // This goes into the url for the recipient - sharingKey: toBase64(new Uint8Array(rawKey)), + encrypted_content: packWithIv(contentIv, ciphertext), + encrypted_dek: packWithIv(dekIv, wrappedDek), + sharingKey: toBase64(new Uint8Array(rawDek)), }; }