# CloudLink End-to-end encrypted messaging and cloud drive. The server stores only ciphertext — it cannot read messages or files under any circumstance. ## Features - **Zero-knowledge messaging** — X25519 ECDH key exchange; AES-256-GCM encrypted messages - **Encrypted drive** — Files are encrypted in-browser before upload; server stores opaque blobs - **Real-time delivery** — WebSocket relay; server forwards ciphertext without decrypting - **Two-factor authentication** — TOTP (Google Authenticator, Authy, etc.) with backup codes - **Progressive Web App** — installable from the browser on any OS - **Desktop app** — native Electron wrapper for Mac, Windows, and Linux - **Admin portal** — manage users, storage quotas, and admin flags - **CI/CD** — Gitea Actions builds Docker images and desktop installers on every push --- ## Architecture ``` Browser / Desktop app │ HTTPS + WSS ▼ nginx (TLS termination, reverse proxy) │ ┌────┴──────┐ │ frontend │ React 18 SPA served by nginx:alpine └───────────┘ │ /api/* and /ws proxied ▼ ┌────────────┐ │ backend │ Express + TypeScript, port 3001 └─────┬──────┘ │ ┌─────▼──────┐ │ PostgreSQL │ pg:16-alpine, persistent volume └────────────┘ ``` All encryption and decryption happens exclusively in the browser/client. The backend is a dumb relay and storage layer. --- ## Encryption Model ### Keys | Key | Algorithm | Where it lives | |---|---|---| | Password-derived key | PBKDF2-SHA256 (600 000 iterations) | In-memory only, derived at login | | Master key | AES-256-GCM | Encrypted with derived key, stored on server | | X25519 keypair | X25519 ECDH | Private key encrypted with master key, stored on server; public key stored plaintext | | Conversation key | AES-256-GCM | Per-conversation symmetric key, encrypted with each member's X25519 public key | | File key | AES-256-GCM | Per-file symmetric key, encrypted with owner's master key | ### Message flow 1. Alice opens a conversation with Bob 2. Client generates a random AES-256-GCM conversation key 3. Key is sealed with Bob's X25519 public key (ephemeral ECDH + HKDF + AES-GCM) 4. Ciphertext of the key is stored in `conversation_members.encrypted_key` 5. Each message is encrypted with the conversation key 6. Server stores and relays only ciphertext — it never sees plaintext ### File flow 1. Client generates a random AES-256-GCM file key 2. File is encrypted in-browser with the file key 3. File key is encrypted with the user's master key 4. Encrypted file and encrypted file key are uploaded; filename is also encrypted 5. Server stores opaque bytes at a UUID-based path --- ## Tech Stack ### Backend - Node.js 20 + TypeScript - Express 4, Helmet, express-rate-limit - `ws` WebSocket server - PostgreSQL 16 with pgcrypto - Argon2id for password hashing - `jsonwebtoken` — 15-min access tokens + 30-day refresh tokens - `otplib` for TOTP 2FA - `multer` for file uploads - `zod` for request validation ### Frontend - React 18 + Vite + TypeScript - Tailwind CSS - Zustand (auth + crypto keys in-memory store) - TanStack Query v5 - React Router v6 - `vite-plugin-pwa` (PWA / installable) - Web Crypto API (no external crypto libraries) - `lucide-react` icons - `date-fns` ### Desktop - Electron 33 - electron-store (persistent server URL) - electron-builder (Mac DMG, Windows NSIS, Linux AppImage) ### Infrastructure - Docker + Docker Compose - nginx (TLS, reverse proxy, HSTS, security headers) - Gitea Actions (CI/CD) --- ## Running Locally (Development) ### Prerequisites - Node.js 20+ - Docker + Docker Compose ### 1. Clone and configure ```bash git clone https://git.necloud.us/nate/cloudlink.git cd cloudlink cp .env.example .env # edit values ``` `.env` minimum: ```env POSTGRES_DB=cloudlink POSTGRES_USER=cloudlink POSTGRES_PASSWORD=changeme JWT_SECRET=change-me-at-least-32-chars JWT_REFRESH_SECRET=change-me-at-least-32-chars MAX_FILE_SIZE_MB=100 ``` ### 2. Start the database ```bash docker compose up -d postgres ``` ### 3. Run the backend ```bash cd backend npm install npm run dev # http://localhost:3001 ``` ### 4. Run the frontend ```bash cd frontend npm install npm run dev # http://localhost:5173 ``` Set `VITE_API_URL=http://localhost:3001/api` and `VITE_WS_URL=ws://localhost:3001/ws` in `frontend/.env.local`. --- ## Production Deployment (Docker) ### Prerequisites - Docker + Docker Compose on the server - SSL certificate at `nginx/ssl/fullchain.pem` and `nginx/ssl/privkey.pem` ### Option A — Build locally, run on server ```bash docker compose build docker compose up -d ``` ### Option B — Pull pre-built images from CI (recommended) After a successful Gitea Actions run: ```bash echo "$REGISTRY_TOKEN" | docker login git.necloud.us -u nate --password-stdin docker compose pull backend frontend docker compose up -d --no-build ``` ### Environment variables (production `.env`) ```env POSTGRES_DB=cloudlink POSTGRES_USER=cloudlink POSTGRES_PASSWORD= JWT_SECRET=<64-char-random-string> JWT_REFRESH_SECRET=<64-char-random-string> HOST_PORT=443 MAX_FILE_SIZE_MB=100 ``` Generate strong secrets: ```bash openssl rand -hex 32 ``` ### First admin user After the stack is up, register a normal account, then promote it: ```bash docker exec -i cloudlink-postgres-1 psql -U cloudlink -d cloudlink \ -c "UPDATE users SET is_admin = true WHERE email = 'you@example.com';" ``` Subsequent admins can be toggled from the Admin portal in the UI. --- ## Desktop App ### Install pre-built (from Gitea release) Download the installer for your OS from [Releases](https://git.necloud.us/nate/cloudlink/releases): | OS | File | Install | |---|---|---| | Windows | `CloudLink Setup.exe` | Run the installer, follow wizard | | Linux | `CloudLink.AppImage` | `chmod +x CloudLink.AppImage && ./CloudLink.AppImage` | | Mac | `CloudLink.dmg` | Open DMG, drag to Applications *(built locally — see below)* | On first launch the app prompts for your CloudLink server URL (e.g. `https://cloudlink.example.com`). This is saved and used for all future launches. ### Build locally ```bash cd desktop npm install npm run build:mac # requires macOS npm run build:win # requires macOS or Linux (Wine) npm run build:linux # any platform ``` Outputs go to `desktop/dist/`. --- ## Admin Portal Accessible at `/admin` for users with `is_admin = true`. - View all users, registration date, last seen - Per-user storage usage vs quota (colour-coded bar) - Inline quota editing (MB) - Toggle admin flag per user - Aggregate stats (total users, total storage, total files) --- ## CI/CD (Gitea Actions) Workflow: `.gitea/workflows/docker-build.yml` | Job | Trigger | Action | |---|---|---| | `build-backend` | every push / PR | Build + push `backend:latest` and `backend:` to Gitea registry | | `build-frontend` | every push / PR | Build + push `frontend:latest` and `frontend:` | | `build-desktop` | every push / PR | Build Linux AppImage + Windows NSIS; upload as workflow artifacts (30-day retention) | | `deploy` | push to `master` | SSH into server, pull new images, restart containers | | `release` | push of a `v*` tag | Create Gitea release, attach desktop installers | ### Required secrets (Repo → Settings → Secrets) | Secret | Value | |---|---| | `REGISTRY_USER` | Gitea username (e.g. `nate`) | | `REGISTRY_TOKEN` | Gitea API token with `package:write` scope | | `DEPLOY_HOST` | Server hostname or IP | | `DEPLOY_USER` | SSH user | | `DEPLOY_SSH_KEY` | SSH private key (contents of `~/.ssh/id_ed25519`) | | `DEPLOY_PATH` | Absolute path to cloudlink on server (e.g. `/opt/cloudlink`) | ### Tagging a release ```bash git tag v1.2.0 && git push origin v1.2.0 ``` This triggers the `release` job which creates a Gitea release entry and attaches the built installers. ### Runner setup A Gitea act runner is required. Install on your server: ```bash wget https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64 -O act_runner chmod +x act_runner # Get registration token from: # Gitea → Site Administration → Actions → Runners → Create Runner ./act_runner register \ --no-interactive \ --instance https://git.necloud.us \ --token \ --name cloudlink-runner \ --labels ubuntu-latest:docker://node:20-bullseye ./act_runner daemon ``` The runner host must have Docker installed. --- ## Security Notes - Crypto keys are **never** written to disk or localStorage — they live in Zustand's in-memory store and are lost on page reload (by design). Users re-derive them from their password at each login. - The server stores only: `password_hash` (Argon2id), `kdf_salt`, `encrypted_master_key`, `encrypted_private_key`, and ciphertext blobs. No plaintext content. - JWTs are short-lived (15 min). Refresh tokens are rotated on use and hashed before storage. - nginx enforces HSTS, X-Frame-Options, X-Content-Type-Options, and a strict CSP. - File paths on disk are UUID-based — not guessable from filenames. - Rate limiting is applied to auth endpoints. --- ## Database Schema See [`backend/src/db/schema.sql`](backend/src/db/schema.sql). Tables: | Table | Purpose | |---|---| | `users` | Account credentials, KDF salt, encrypted master key, 2FA state, quota | | `user_keys` | X25519 public key + encrypted private key | | `refresh_tokens` | Hashed refresh tokens with expiry | | `conversations` | Direct message threads | | `conversation_members` | Per-member encrypted conversation key | | `messages` | Ciphertext-only message records | | `files` | Encrypted file metadata + storage path | --- ## Repository Source: `https://git.necloud.us/nate/cloudlink` Container registry: `git.necloud.us/nate/cloudlink/{backend,frontend}:latest`