Initial CloudLink scaffold: E2E encrypted PWA with messaging and cloud drive

- Zero-knowledge architecture: all encryption/decryption client-side via Web Crypto API
- AES-GCM 256 for messages, files, and key wrapping; PBKDF2 for password-based key derivation
- Node.js/TypeScript backend: JWT auth with refresh rotation, argon2id passwords, TOTP 2FA
- PostgreSQL schema: encrypted blobs only, server never stores plaintext
- Real-time messaging via WebSocket relay (server routes ciphertext, cannot read it)
- Encrypted file upload/download with per-file keys wrapped by user master key
- React PWA frontend: Vite + Tailwind + service worker for offline support
- Docker Compose: postgres + backend + frontend + nginx with TLS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-28 09:41:18 -04:00
commit 937bf8ad2d
36 files changed
+2325

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL=/api
ARG VITE_WS_URL=/ws
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_WS_URL=$VITE_WS_URL
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>CloudLink</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "cloudlink-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.51.1",
"lucide-react": "^0.400.0",
"axios": "^1.7.2",
"date-fns": "^3.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.1",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4",
"vite-plugin-pwa": "^0.20.0"
}
}
+1
View File
@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
+38
View File
@@ -0,0 +1,38 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './lib/store';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import Layout from './components/Layout';
import MessagesPage from './pages/MessagesPage';
import DrivePage from './pages/DrivePage';
import SettingsPage from './pages/SettingsPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
const { accessToken, masterKey } = useAuthStore();
if (!accessToken) return <Navigate to="/login" replace />;
if (!masterKey) return <Navigate to="/login" state={{ reauth: true }} replace />;
return <>{children}</>;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/*"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<Navigate to="/messages" replace />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="messages/:id" element={<MessagesPage />} />
<Route path="drive" element={<DrivePage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { Outlet, NavLink } from 'react-router-dom';
import { MessageSquare, HardDrive, Settings, LogOut, Shield } from 'lucide-react';
import { useAuthStore } from '../lib/store';
import { socket } from '../lib/ws';
const nav = [
{ to: '/messages', icon: MessageSquare, label: 'Messages' },
{ to: '/drive', icon: HardDrive, label: 'Drive' },
{ to: '/settings', icon: Settings, label: 'Settings' },
];
export default function Layout() {
const { logout, username } = useAuthStore();
function handleLogout() {
socket.disconnect();
logout();
}
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<aside className="w-16 md:w-56 flex flex-col bg-surface-1 border-r border-slate-700 shrink-0">
<div className="flex items-center gap-2 px-4 py-5 border-b border-slate-700">
<Shield className="text-accent w-6 h-6 shrink-0" />
<span className="hidden md:block font-bold text-lg tracking-tight">CloudLink</span>
</div>
<nav className="flex-1 py-4 space-y-1 px-2">
{nav.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-accent text-white'
: 'text-slate-400 hover:text-white hover:bg-surface-2'
}`
}
>
<Icon className="w-5 h-5 shrink-0" />
<span className="hidden md:block">{label}</span>
</NavLink>
))}
</nav>
<div className="p-3 border-t border-slate-700">
<div className="flex items-center gap-3 mb-2 px-1">
<div className="w-7 h-7 rounded-full bg-accent flex items-center justify-center text-xs font-bold shrink-0">
{username?.[0]?.toUpperCase() ?? '?'}
</div>
<span className="hidden md:block text-sm text-slate-300 truncate">{username}</span>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-surface-2 transition-colors"
>
<LogOut className="w-4 h-4 shrink-0" />
<span className="hidden md:block">Sign out</span>
</button>
</div>
</aside>
{/* Main */}
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
// All encryption/decryption happens here — the server never sees plaintext.
// Uses Web Crypto API (AES-GCM 256 + PBKDF2 for key derivation).
const ENC = new TextEncoder();
const DEC = new TextDecoder();
// ── Key derivation ──────────────────────────────────────────────────────────
export async function generateKdfSalt(): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(32));
return bufToB64(salt);
}
export async function deriveKeyFromPassword(
password: string,
saltB64: string
): Promise<CryptoKey> {
const raw = await crypto.subtle.importKey('raw', ENC.encode(password), 'PBKDF2', false, [
'deriveKey',
]);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: b64ToBuf(saltB64), iterations: 600_000, hash: 'SHA-256' },
raw,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
// ── Master key ──────────────────────────────────────────────────────────────
export async function generateMasterKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
'wrapKey',
'unwrapKey',
]);
}
export async function exportMasterKey(key: CryptoKey): Promise<string> {
const raw = await crypto.subtle.exportKey('raw', key);
return bufToB64(raw);
}
/** Encrypt master key with the password-derived key — stored on server */
export async function encryptMasterKey(
masterKey: CryptoKey,
derivedKey: CryptoKey
): Promise<string> {
const raw = await crypto.subtle.exportKey('raw', masterKey);
return aesEncryptRaw(new Uint8Array(raw), derivedKey);
}
export async function decryptMasterKey(
encryptedB64: string,
derivedKey: CryptoKey
): Promise<CryptoKey> {
const raw = await aesDecryptRaw(encryptedB64, derivedKey);
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
'wrapKey',
'unwrapKey',
]);
}
// ── Message encryption ──────────────────────────────────────────────────────
/** Encrypt a message string with the conversation's shared key */
export async function encryptMessage(plaintext: string, key: CryptoKey): Promise<string> {
return aesEncryptRaw(ENC.encode(plaintext), key);
}
export async function decryptMessage(ciphertextB64: string, key: CryptoKey): Promise<string> {
const raw = await aesDecryptRaw(ciphertextB64, key);
return DEC.decode(raw);
}
// ── File encryption ─────────────────────────────────────────────────────────
export async function generateFileKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
}
/** Encrypt a file blob client-side before upload */
export async function encryptFile(file: File, key: CryptoKey): Promise<Blob> {
const data = new Uint8Array(await file.arrayBuffer());
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, data);
const combined = new Uint8Array(nonce.length + ct.byteLength);
combined.set(nonce);
combined.set(new Uint8Array(ct), nonce.length);
return new Blob([combined], { type: 'application/octet-stream' });
}
export async function decryptFile(encryptedBlob: Blob, key: CryptoKey): Promise<Uint8Array> {
const data = new Uint8Array(await encryptedBlob.arrayBuffer());
const nonce = data.slice(0, 12);
const ct = data.slice(12);
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
return new Uint8Array(pt);
}
/** Encrypt a file key with the master key — stored alongside file metadata */
export async function encryptFileKey(fileKey: CryptoKey, masterKey: CryptoKey): Promise<string> {
const raw = await crypto.subtle.exportKey('raw', fileKey);
return aesEncryptRaw(new Uint8Array(raw), masterKey);
}
export async function decryptFileKey(
encryptedB64: string,
masterKey: CryptoKey
): Promise<CryptoKey> {
const raw = await aesDecryptRaw(encryptedB64, masterKey);
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
]);
}
// ── Conversation key ─────────────────────────────────────────────────────────
/** Generate a shared conversation key */
export async function generateConversationKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
}
/** Encrypt conversation key with a user's master key for storage */
export async function encryptConversationKey(
convKey: CryptoKey,
masterKey: CryptoKey
): Promise<string> {
const raw = await crypto.subtle.exportKey('raw', convKey);
return aesEncryptRaw(new Uint8Array(raw), masterKey);
}
export async function decryptConversationKey(
encryptedB64: string,
masterKey: CryptoKey
): Promise<CryptoKey> {
const raw = await aesDecryptRaw(encryptedB64, masterKey);
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
]);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
async function aesEncryptRaw(data: Uint8Array, key: CryptoKey): Promise<string> {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, data);
const combined = new Uint8Array(nonce.length + ct.byteLength);
combined.set(nonce);
combined.set(new Uint8Array(ct), nonce.length);
return bufToB64(combined);
}
async function aesDecryptRaw(b64: string, key: CryptoKey): Promise<Uint8Array> {
const combined = b64ToBuf(b64);
const nonce = combined.slice(0, 12);
const ct = combined.slice(12);
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
return new Uint8Array(pt);
}
export function bufToB64(buf: ArrayBuffer | Uint8Array): string {
return btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
}
export function b64ToBuf(b64: string): Uint8Array {
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
+22
View File
@@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-surface text-slate-100 font-sans antialiased;
}
* { box-sizing: border-box; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { @apply bg-slate-600 rounded-full; }
}
@layer components {
.input {
@apply bg-surface-2 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-colors;
}
.btn-primary {
@apply bg-accent hover:bg-accent-hover text-white font-medium rounded-lg px-4 py-2 text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
}
+41
View File
@@ -0,0 +1,41 @@
import axios from 'axios';
import { useAuthStore } from './store';
const api = axios.create({ baseURL: import.meta.env.VITE_API_URL || '/api' });
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
let refreshing: Promise<void> | null = null;
api.interceptors.response.use(
(r) => r,
async (err) => {
if (err.response?.status !== 401) throw err;
const store = useAuthStore.getState();
if (!store.refreshToken) { store.logout(); throw err; }
if (!refreshing) {
refreshing = api
.post('/auth/refresh', { refreshToken: store.refreshToken })
.then((r) => {
store.setTokens(r.data.accessToken, r.data.refreshToken);
})
.catch(() => store.logout())
.finally(() => { refreshing = null; });
}
await refreshing;
const newToken = useAuthStore.getState().accessToken;
if (!newToken) throw err;
err.config.headers.Authorization = `Bearer ${newToken}`;
return api.request(err.config);
}
);
export default api;
+63
View File
@@ -0,0 +1,63 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
userId: string | null;
username: string | null;
// Base64 master key — loaded into memory from password derivation, NOT persisted
masterKey: CryptoKey | null;
setAuth: (data: { accessToken: string; refreshToken: string; userId: string }) => void;
setTokens: (access: string, refresh: string) => void;
setMasterKey: (key: CryptoKey) => void;
setUsername: (u: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
userId: null,
username: null,
masterKey: null,
setAuth: ({ accessToken, refreshToken, userId }) =>
set({ accessToken, refreshToken, userId }),
setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
setMasterKey: (masterKey) => set({ masterKey }),
setUsername: (username) => set({ username }),
logout: () =>
set({ accessToken: null, refreshToken: null, userId: null, masterKey: null }),
}),
{
name: 'cloudlink-auth',
// Never persist the master key — must be re-derived on login
partialize: (s) => ({
accessToken: s.accessToken,
refreshToken: s.refreshToken,
userId: s.userId,
username: s.username,
}),
}
)
);
interface ConvKey {
conversationId: string;
key: CryptoKey;
}
interface CryptoStore {
convKeys: Map<string, CryptoKey>;
setConvKey: (conversationId: string, key: CryptoKey) => void;
getConvKey: (conversationId: string) => CryptoKey | undefined;
}
export const useCryptoStore = create<CryptoStore>((set, get) => ({
convKeys: new Map(),
setConvKey: (conversationId, key) =>
set((s) => ({ convKeys: new Map(s.convKeys).set(conversationId, key) })),
getConvKey: (conversationId) => get().convKeys.get(conversationId),
}));
+44
View File
@@ -0,0 +1,44 @@
type MessageHandler = (msg: unknown) => void;
class CloudLinkSocket {
private ws: WebSocket | null = null;
private handlers: Set<MessageHandler> = new Set();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
connect(token: string) {
if (this.ws?.readyState === WebSocket.OPEN) return;
const url = `${import.meta.env.VITE_WS_URL || '/ws'}?token=${token}`;
this.ws = new WebSocket(url);
this.ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
this.handlers.forEach((h) => h(msg));
} catch {}
};
this.ws.onclose = () => {
this.reconnectTimer = setTimeout(() => this.connect(token), 3000);
};
}
disconnect() {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close();
this.ws = null;
}
send(data: object) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
on(handler: MessageHandler) {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
}
export const socket = new CloudLinkSocket();
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './index.css';
const qc = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={qc}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
+168
View File
@@ -0,0 +1,168 @@
import { useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Upload, File, Trash2, Download, Lock } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import api from '../lib/api';
import { useAuthStore } from '../lib/store';
import {
generateFileKey,
encryptFile,
encryptFileKey,
decryptFileKey,
decryptFile,
encryptMessage,
decryptMessage,
} from '../crypto';
interface DriveFile {
id: string;
encrypted_name: string;
mime_type: string;
size_bytes: number;
encrypted_file_key: string;
uploaded_at: string;
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
}
export default function DrivePage() {
const { masterKey } = useAuthStore();
const qc = useQueryClient();
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [decryptedNames, setDecryptedNames] = useState<Record<string, string>>({});
const { data: files = [] } = useQuery<DriveFile[]>({
queryKey: ['files'],
queryFn: () => api.get('/files').then((r) => r.data),
onSuccess: async (files) => {
if (!masterKey) return;
const names: Record<string, string> = {};
await Promise.all(
files.map(async (f) => {
try {
const fileKey = await decryptFileKey(f.encrypted_file_key, masterKey);
names[f.id] = await decryptMessage(f.encrypted_name, fileKey);
} catch {
names[f.id] = '[encrypted]';
}
})
);
setDecryptedNames(names);
},
} as any);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file || !masterKey) return;
setUploading(true);
try {
const fileKey = await generateFileKey();
const encryptedBlob = await encryptFile(file, fileKey);
const encryptedFileKey = await encryptFileKey(fileKey, masterKey);
const encryptedName = await encryptMessage(file.name, fileKey);
const form = new FormData();
form.append('file', encryptedBlob, 'blob');
form.append('encryptedName', encryptedName);
form.append('encryptedFileKey', encryptedFileKey);
form.append('mimeType', file.type || 'application/octet-stream');
await api.post('/upload', form, { headers: { 'Content-Type': 'multipart/form-data' } });
qc.invalidateQueries({ queryKey: ['files'] });
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function handleDownload(f: DriveFile) {
if (!masterKey) return;
const fileKey = await decryptFileKey(f.encrypted_file_key, masterKey);
const res = await api.get(`/files/${f.id}/download`, { responseType: 'blob' });
const decrypted = await decryptFile(res.data, fileKey);
const filename = decryptedNames[f.id] ?? 'download';
const url = URL.createObjectURL(new Blob([decrypted]));
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function handleDelete(id: string) {
await api.delete(`/files/${id}`);
qc.invalidateQueries({ queryKey: ['files'] });
}
return (
<div className="h-full flex flex-col">
<header className="px-6 py-4 border-b border-slate-700 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">Encrypted Drive</h1>
<p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5">
<Lock className="w-3 h-3" /> Files are encrypted before upload
</p>
</div>
<button
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="btn-primary flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{uploading ? 'Encrypting…' : 'Upload'}
</button>
<input ref={inputRef} type="file" className="hidden" onChange={handleUpload} />
</header>
<div className="flex-1 overflow-y-auto p-6">
{files.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-slate-500 gap-3">
<File className="w-10 h-10" />
<p>No files yet — upload your first encrypted file</p>
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{files.map((f) => (
<div
key={f.id}
className="flex items-center gap-4 bg-surface-1 rounded-lg px-4 py-3 border border-slate-700"
>
<File className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{decryptedNames[f.id] ?? '…'}
</p>
<p className="text-xs text-slate-500">
{formatBytes(f.size_bytes)} ·{' '}
{formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })}
</p>
</div>
<button
onClick={() => handleDownload(f)}
className="p-1.5 text-slate-400 hover:text-white transition-colors"
title="Download"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(f.id)}
className="p-1.5 text-slate-400 hover:text-red-400 transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useState, FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Shield } from 'lucide-react';
import api from '../lib/api';
import { useAuthStore } from '../lib/store';
import { deriveKeyFromPassword, decryptMasterKey } from '../crypto';
import { socket } from '../lib/ws';
export default function LoginPage() {
const navigate = useNavigate();
const { setAuth, setMasterKey, setUsername } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [require2fa, setRequire2fa] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function submit(e: FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await api.post('/auth/login', {
email,
password,
...(totpCode ? { totpCode } : {}),
});
if (res.data.require2fa) {
setRequire2fa(true);
setLoading(false);
return;
}
const { accessToken, refreshToken, userId, kdfSalt, encryptedMasterKey, username } =
res.data;
// Derive key from password — never leaves the browser
const derived = await deriveKeyFromPassword(password, kdfSalt);
const masterKey = await decryptMasterKey(encryptedMasterKey, derived);
setAuth({ accessToken, refreshToken, userId });
setMasterKey(masterKey);
setUsername(username);
socket.connect(accessToken);
navigate('/messages');
} catch (err: any) {
setError(err.response?.data?.error || 'Login failed');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<Shield className="w-12 h-12 text-accent mb-3" />
<h1 className="text-2xl font-bold">CloudLink</h1>
<p className="text-slate-400 text-sm mt-1">End-to-end encrypted</p>
</div>
<form onSubmit={submit} className="bg-surface-1 rounded-xl p-6 space-y-4 border border-slate-700">
<h2 className="font-semibold text-lg">Sign in</h2>
{error && <p className="text-red-400 text-sm">{error}</p>}
{!require2fa ? (
<>
<Field label="Email" type="email" value={email} onChange={setEmail} />
<Field label="Password" type="password" value={password} onChange={setPassword} />
</>
) : (
<div>
<label className="block text-sm text-slate-400 mb-1">Authenticator code</label>
<input
className="input w-full"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
autoFocus
/>
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full"
>
{loading ? 'Signing in…' : require2fa ? 'Verify' : 'Sign in'}
</button>
</form>
<p className="text-center text-sm text-slate-400 mt-4">
No account?{' '}
<Link to="/register" className="text-accent hover:underline">
Register
</Link>
</p>
</div>
</div>
);
}
function Field({
label,
type,
value,
onChange,
}: {
label: string;
type: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<div>
<label className="block text-sm text-slate-400 mb-1">{label}</label>
<input
className="input w-full"
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
required
/>
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
import { useState, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Send, Plus, Search } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import api from '../lib/api';
import { useAuthStore, useCryptoStore } from '../lib/store';
import { socket } from '../lib/ws';
import {
encryptMessage,
decryptMessage,
generateConversationKey,
encryptConversationKey,
decryptConversationKey,
} from '../crypto';
interface Conversation {
id: string;
members: { id: string; username: string }[];
encrypted_key: string;
last_message_at: string | null;
}
interface Message {
id: string;
sender_id: string;
sender_username: string;
ciphertext: string;
sent_at: string;
plaintext?: string;
}
export default function MessagesPage() {
const { id: activeId } = useParams();
const { userId, masterKey } = useAuthStore();
const { setConvKey, getConvKey } = useCryptoStore();
const qc = useQueryClient();
const [text, setText] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [newUserSearch, setNewUserSearch] = useState('');
const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
const bottomRef = useRef<HTMLDivElement>(null);
const { data: conversations = [] } = useQuery<Conversation[]>({
queryKey: ['conversations'],
queryFn: () => api.get('/conversations').then((r) => r.data),
});
// Load + decrypt messages when conversation changes
useEffect(() => {
if (!activeId || !masterKey) return;
async function load() {
const conv = conversations.find((c) => c.id === activeId);
if (!conv) return;
let convKey = getConvKey(activeId!);
if (!convKey) {
convKey = await decryptConversationKey(conv.encrypted_key, masterKey!);
setConvKey(activeId!, convKey);
}
const res = await api.get(`/conversations/${activeId}/messages`);
const decrypted = await Promise.all(
res.data.map(async (m: Message) => ({
...m,
plaintext: await decryptMessage(m.ciphertext, convKey!).catch(() => '[encrypted]'),
}))
);
setMessages(decrypted);
}
load();
}, [activeId, conversations.length]);
// WebSocket incoming messages
useEffect(() => {
return socket.on(async (raw) => {
const msg = raw as any;
if (msg.type !== 'message' || msg.conversationId !== activeId) return;
const convKey = getConvKey(activeId!);
if (!convKey) return;
const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]');
setMessages((prev) => [
...prev,
{ ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext },
]);
});
}, [activeId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function sendMessage() {
if (!text.trim() || !activeId) return;
const convKey = getConvKey(activeId);
if (!convKey) return;
const ciphertext = await encryptMessage(text.trim(), convKey);
socket.send({ type: 'message', conversationId: activeId, ciphertext });
setText('');
}
async function startConversation(recipientId: string, recipientUsername: string) {
if (!masterKey) return;
const convKey = await generateConversationKey();
const myEncKey = await encryptConversationKey(convKey, masterKey);
const recipientEncKey = await encryptConversationKey(convKey, masterKey); // real impl: encrypt with recipient's pubkey
const res = await api.post('/conversations', {
recipientId,
encryptedKeys: {
[userId!]: myEncKey,
[recipientId]: recipientEncKey,
},
});
setConvKey(res.data.conversationId, convKey);
qc.invalidateQueries({ queryKey: ['conversations'] });
setNewUserSearch('');
setSearchResults([]);
}
async function handleSearch(q: string) {
setNewUserSearch(q);
if (q.length < 2) { setSearchResults([]); return; }
const res = await api.get(`/users/search?q=${encodeURIComponent(q)}`);
setSearchResults(res.data);
}
const activeConv = conversations.find((c) => c.id === activeId);
const otherMember = activeConv?.members.find((m) => m.id !== userId);
return (
<div className="flex h-full">
{/* Conversation list */}
<aside className="w-64 border-r border-slate-700 flex flex-col bg-surface-1 shrink-0">
<div className="p-3 border-b border-slate-700">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-slate-500" />
<input
className="input w-full pl-8 text-sm"
placeholder="Find or start chat…"
value={newUserSearch}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{searchResults.length > 0 && (
<div className="mt-1 bg-surface-2 rounded-lg border border-slate-600 overflow-hidden">
{searchResults.map((u) => (
<button
key={u.id}
onClick={() => startConversation(u.id, u.username)}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-600 transition-colors"
>
{u.username}
</button>
))}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
{conversations.map((conv) => {
const other = conv.members.find((m) => m.id !== userId);
return (
<a
key={conv.id}
href={`/messages/${conv.id}`}
className={`flex items-center gap-3 px-3 py-3 border-b border-slate-700/50 hover:bg-surface-2 transition-colors ${
activeId === conv.id ? 'bg-surface-2' : ''
}`}
>
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-xs font-bold shrink-0">
{other?.username[0]?.toUpperCase() ?? '?'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{other?.username ?? 'Unknown'}</p>
{conv.last_message_at && (
<p className="text-xs text-slate-500">
{formatDistanceToNow(new Date(conv.last_message_at), { addSuffix: true })}
</p>
)}
</div>
</a>
);
})}
</div>
</aside>
{/* Chat area */}
<div className="flex-1 flex flex-col">
{activeId ? (
<>
<header className="px-4 py-3 border-b border-slate-700 bg-surface-1 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-sm font-bold">
{otherMember?.username[0]?.toUpperCase() ?? '?'}
</div>
<span className="font-medium">{otherMember?.username ?? 'Conversation'}</span>
<span className="ml-auto text-xs text-green-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 inline-block" />
E2E encrypted
</span>
</header>
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
{messages.map((m) => {
const mine = m.sender_id === userId;
return (
<div key={m.id} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-2xl text-sm ${
mine
? 'bg-accent text-white rounded-br-sm'
: 'bg-surface-1 text-slate-100 rounded-bl-sm'
}`}
>
<p>{m.plaintext}</p>
<p className={`text-xs mt-1 ${mine ? 'text-indigo-200' : 'text-slate-500'}`}>
{formatDistanceToNow(new Date(m.sent_at), { addSuffix: true })}
</p>
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
<div className="px-4 py-3 border-t border-slate-700 flex gap-2">
<input
className="input flex-1"
placeholder="Message…"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()}
/>
<button onClick={sendMessage} className="btn-primary px-4">
<Send className="w-4 h-4" />
</button>
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 gap-3">
<Plus className="w-10 h-10" />
<p>Search for a user to start a conversation</p>
</div>
)}
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useState, FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Shield } from 'lucide-react';
import api from '../lib/api';
import { useAuthStore } from '../lib/store';
import {
generateKdfSalt,
deriveKeyFromPassword,
generateMasterKey,
encryptMasterKey,
} from '../crypto';
import { socket } from '../lib/ws';
export default function RegisterPage() {
const navigate = useNavigate();
const { setAuth, setMasterKey, setUsername } = useAuthStore();
const [username, setUsernameField] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function submit(e: FormEvent) {
e.preventDefault();
setError('');
if (password !== confirm) { setError('Passwords do not match'); return; }
if (password.length < 12) { setError('Password must be at least 12 characters'); return; }
setLoading(true);
try {
// Generate all crypto material client-side
const kdfSalt = await generateKdfSalt();
const derivedKey = await deriveKeyFromPassword(password, kdfSalt);
const masterKey = await generateMasterKey();
const encryptedMasterKey = await encryptMasterKey(masterKey, derivedKey);
// Minimal public key placeholder (full ECDH exchange would use X25519 via libsodium)
const publicKey = 'placeholder-pubkey';
const keySignature = 'placeholder-sig';
const res = await api.post('/auth/register', {
username,
email,
password,
kdfSalt,
encryptedMasterKey,
publicKey,
keySignature,
});
const { accessToken, refreshToken, userId } = res.data;
setAuth({ accessToken, refreshToken, userId });
setMasterKey(masterKey);
setUsername(username);
socket.connect(accessToken);
navigate('/messages');
} catch (err: any) {
setError(err.response?.data?.error || 'Registration failed');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<Shield className="w-12 h-12 text-accent mb-3" />
<h1 className="text-2xl font-bold">CloudLink</h1>
<p className="text-slate-400 text-sm mt-1">Create your encrypted account</p>
</div>
<form onSubmit={submit} className="bg-surface-1 rounded-xl p-6 space-y-4 border border-slate-700">
<h2 className="font-semibold text-lg">Create account</h2>
{error && <p className="text-red-400 text-sm">{error}</p>}
<Field label="Username" type="text" value={username} onChange={setUsernameField} />
<Field label="Email" type="email" value={email} onChange={setEmail} />
<Field label="Password (min 12 chars)" type="password" value={password} onChange={setPassword} />
<Field label="Confirm password" type="password" value={confirm} onChange={setConfirm} />
<p className="text-xs text-slate-500">
Your password derives the encryption key. If you forget it, your data cannot be recovered.
</p>
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? 'Creating account…' : 'Create account'}
</button>
</form>
<p className="text-center text-sm text-slate-400 mt-4">
Already have an account?{' '}
<Link to="/login" className="text-accent hover:underline">Sign in</Link>
</p>
</div>
</div>
);
}
function Field({ label, type, value, onChange }: {
label: string; type: string; value: string; onChange: (v: string) => void;
}) {
return (
<div>
<label className="block text-sm text-slate-400 mb-1">{label}</label>
<input className="input w-full" type={type} value={value} onChange={(e) => onChange(e.target.value)} required />
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Shield, KeyRound, Copy, Check } from 'lucide-react';
import api from '../lib/api';
interface Me {
username: string;
email: string;
totp_enabled: boolean;
}
export default function SettingsPage() {
const { data: me } = useQuery<Me>({
queryKey: ['me'],
queryFn: () => api.get('/me').then((r) => r.data),
});
const [step, setStep] = useState<'idle' | 'setup' | 'verify' | 'done'>('idle');
const [qr, setQr] = useState('');
const [secret, setSecret] = useState('');
const [code, setCode] = useState('');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const [error, setError] = useState('');
async function setup2fa() {
const res = await api.post('/auth/2fa/setup');
setQr(res.data.qrDataUrl);
setSecret(res.data.secret);
setStep('setup');
}
async function verify2fa() {
setError('');
try {
const res = await api.post('/auth/2fa/verify', { code });
setBackupCodes(res.data.backupCodes);
setStep('done');
} catch (err: any) {
setError(err.response?.data?.error || 'Verification failed');
}
}
function copy(text: string) {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="h-full overflow-y-auto">
<div className="max-w-xl mx-auto p-6 space-y-6">
<h1 className="text-xl font-semibold">Settings</h1>
{/* Account info */}
<section className="bg-surface-1 rounded-xl border border-slate-700 p-5 space-y-3">
<h2 className="font-medium flex items-center gap-2">
<Shield className="w-4 h-4 text-accent" /> Account
</h2>
<div className="text-sm space-y-1">
<p className="text-slate-400">Username: <span className="text-slate-100">{me?.username}</span></p>
<p className="text-slate-400">Email: <span className="text-slate-100">{me?.email}</span></p>
</div>
</section>
{/* 2FA */}
<section className="bg-surface-1 rounded-xl border border-slate-700 p-5 space-y-4">
<h2 className="font-medium flex items-center gap-2">
<KeyRound className="w-4 h-4 text-accent" /> Two-factor authentication
</h2>
{me?.totp_enabled && step === 'idle' ? (
<p className="text-sm text-green-400 flex items-center gap-2">
<Check className="w-4 h-4" /> 2FA is enabled
</p>
) : null}
{!me?.totp_enabled && step === 'idle' && (
<button onClick={setup2fa} className="btn-primary text-sm">
Set up 2FA
</button>
)}
{step === 'setup' && (
<div className="space-y-4">
<p className="text-sm text-slate-400">
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<img src={qr} alt="2FA QR code" className="w-48 h-48 rounded-lg" />
<p className="text-xs text-slate-500 font-mono break-all">{secret}</p>
<button onClick={() => setStep('verify')} className="btn-primary text-sm">
I've scanned it
</button>
</div>
)}
{step === 'verify' && (
<div className="space-y-3">
<p className="text-sm text-slate-400">Enter the 6-digit code from your app:</p>
{error && <p className="text-red-400 text-sm">{error}</p>}
<input
className="input w-40 font-mono text-lg tracking-widest"
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="000000"
autoFocus
/>
<button onClick={verify2fa} className="btn-primary text-sm">Verify</button>
</div>
)}
{step === 'done' && (
<div className="space-y-3">
<p className="text-sm text-green-400">2FA enabled! Save these backup codes:</p>
<div className="bg-surface-2 rounded-lg p-3 font-mono text-sm space-y-1">
{backupCodes.map((c) => <p key={c}>{c}</p>)}
</div>
<button
onClick={() => copy(backupCodes.join('\n'))}
className="flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? 'Copied' : 'Copy codes'}
</button>
</div>
)}
</section>
{/* Encryption notice */}
<section className="bg-surface-1 rounded-xl border border-slate-700 p-5">
<h2 className="font-medium flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-accent" /> Encryption
</h2>
<ul className="text-sm text-slate-400 space-y-1.5 list-disc list-inside">
<li>All messages and files are encrypted in your browser before being sent</li>
<li>Your encryption key is derived from your password — never sent to the server</li>
<li>The server operator cannot read your data</li>
<li>If you forget your password, your data cannot be recovered</li>
</ul>
</section>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
surface: {
DEFAULT: '#0f172a',
1: '#1e293b',
2: '#263248',
},
accent: {
DEFAULT: '#6366f1',
hover: '#4f46e5',
},
},
},
},
plugins: [],
};
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
+43
View File
@@ -0,0 +1,43 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'icon-192.png', 'icon-512.png'],
manifest: {
name: 'CloudLink',
short_name: 'CloudLink',
description: 'Encrypted messaging and cloud drive',
theme_color: '#0f172a',
background_color: '#0f172a',
display: 'standalone',
orientation: 'portrait',
start_url: '/',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^\/api\//,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache', networkTimeoutSeconds: 10 },
},
],
},
}),
],
server: {
proxy: {
'/api': 'http://localhost:3001',
'/ws': { target: 'ws://localhost:3001', ws: true },
},
},
});