From d92e899fc7a32694162b0bcf203656cd8efee4d1 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:48:46 -0400 Subject: [PATCH] docs: comprehensive README covering architecture, encryption model, deployment, CI/CD, and desktop app Co-Authored-By: Claude Sonnet 4.6 --- README.md | 342 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..db32258 --- /dev/null +++ b/README.md @@ -0,0 +1,342 @@ +# 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`