diff --git a/ecosystem/intro.md b/ecosystem/intro.md index e96963f..68b4a2b 100644 --- a/ecosystem/intro.md +++ b/ecosystem/intro.md @@ -13,8 +13,9 @@ The MSK Ecosystem is a growing family of self-hosted, privacy-friendly web tools - **[MSK Paste](msk-paste/index.md)** — A self-hosted pastebin alternative with syntax highlighting, password protection, and burn-after-read pastes. - **[MSK Shortener](msk-shortener/index.md)** — A self-hosted URL shortener with anonymous click statistics, QR codes, and link expiration. +- **[MSKanban](mskanban/index.md)** — A self-hostable, zero-knowledge Kanban board with real-time CRDT sync, end-to-end encryption, and built-in analytics. -More projects are planned — including MSKanban, MSK Banking, and more. +More projects are planned — including MSK Banking, and more. --- diff --git a/ecosystem/mskanban/_category_.json b/ecosystem/mskanban/_category_.json new file mode 100644 index 0000000..bb4fbb0 --- /dev/null +++ b/ecosystem/mskanban/_category_.json @@ -0,0 +1,9 @@ +{ + "label": "MSKanban", + "position": 4, + "collapsed": true, + "link": { + "type": "doc", + "id": "mskanban/index" + } +} diff --git a/ecosystem/mskanban/api.md b/ecosystem/mskanban/api.md new file mode 100644 index 0000000..4d3694c --- /dev/null +++ b/ecosystem/mskanban/api.md @@ -0,0 +1,213 @@ +--- +title: REST API +sidebar_position: 5 +--- + +# REST API + +Programmatic access for scripts, CLI tools, and integrations. + +:::tip +The API surfaces **metadata and ciphertext** — it does not expose plaintext. To work with card content programmatically you need to implement the [crypto layer](privacy.md#key-hierarchy) client-side and present the right Workspace / Board Key. A small example CLI is on the roadmap; until then, the test fixtures under `tests/integration/` in the source repo are the best reference. +::: + +--- + +## Base URL & auth + +- **Base URL**: `https://kanban.your-domain.com/api` +- **Auth**: session cookie. Log in once via `POST /api/auth/login` (browser does it on the login page), then reuse the resulting `Set-Cookie` for subsequent calls. The cookie is `HttpOnly` + `Secure` + `SameSite=Strict`. + +For CLI use without a browser, the recommended pattern is a long-lived session via curl-with-cookies: + +```bash +# 1. Log in (returns a Set-Cookie that you save) +curl -c cookies.txt -b cookies.txt -X POST \ + -H 'Content-Type: application/json' \ + -d '{"email":"you@example.com","authHash":"...","totp":"123456"}' \ + https://kanban.your-domain.com/api/auth/login + +# 2. Reuse cookies.txt on every subsequent call +curl -b cookies.txt https://kanban.your-domain.com/api/me +``` + +:::warning +`authHash` is the Argon2id-derived auth hash, **not** the plaintext password. Deriving it without a browser requires the same Argon2id parameters MSKanban uses (m=64MB, t=3, p=4) plus the per-user salt returned by `POST /api/auth/login-init`. There is no "send the raw password" shortcut — that's the whole point of the zero-knowledge layer. +::: + +--- + +## Conventions + +- **Content-Type**: `application/json` for request and response bodies +- **Error shape**: + ```json + { "error": { "code": "BAD_REQUEST", "message": "human-readable" } } + ``` +- **Encrypted fields** are always strings of the form `v1..` (Base64url-encoded). They round-trip unchanged through the API — the server never touches them +- **IDs** are CUIDs (`c…`), opaque, URL-safe, ≤ 64 chars +- **Timestamps** are ISO 8601 with offset (`2026-05-25T12:34:56.789Z`) + +--- + +## Endpoint reference + +The full surface is ~56 routes. The OpenAPI 3.1 spec lives in the project repo at [`docs/api/openapi.yaml`](https://github.com/MSK-Scripts/mskanban/blob/main/docs/api/openapi.yaml) and is the authoritative reference. The table below is a curated index. + +### Auth + +| Method | Path | Notes | +|---|---|---| +| `POST` | `/auth/register` | Create account with `{email, authHash, publicKey, encPrivateKey, encSymmetricKey, encRecoveryBlob}` | +| `POST` | `/auth/login` | `{email, authHash, totp?}` → session cookie | +| `POST` | `/auth/logout` | Invalidate current session | +| `POST` | `/auth/recovery` | Recovery-key-based reset | +| `POST` | `/auth/2fa/enroll` | Generate TOTP secret | +| `POST` | `/auth/2fa/verify` | Confirm enrollment | +| `POST` | `/auth/2fa/disable` | Remove TOTP | +| `POST` | `/auth/webauthn/register` | Begin WebAuthn registration | +| `POST` | `/auth/webauthn/verify` | Finish WebAuthn registration | +| `POST` | `/auth/ws-ticket` | One-shot ticket for the WebSocket relay | + +### Self + +| Method | Path | Notes | +|---|---|---| +| `GET` | `/me` | Current user + workspace memberships | +| `GET` | `/me/export` | GDPR Art. 15 export (encrypted blobs + metadata) | +| `DELETE` | `/me` | GDPR Art. 17 delete — crypto-shreds keys immediately, hard-delete after 30 days | +| `GET` | `/me/notifications` | Recent assignment / mention notifications | + +### Workspaces + +| Method | Path | Notes | +|---|---|---| +| `GET / POST` | `/workspaces` | List own / create | +| `GET / PATCH / DELETE` | `/workspaces/:id` | | +| `GET / POST` | `/workspaces/:id/members` | List / invite (with `{userId, encWorkspaceKey}` sealed for the invitee) | +| `DELETE` | `/workspaces/:id/members/:userId` | Remove member | + +### Boards, columns, cards + +| Method | Path | Notes | +|---|---|---| +| `GET / POST` | `/workspaces/:wsId/boards` | List / create boards in a workspace | +| `GET / PATCH / DELETE` | `/boards/:id` | | +| `GET / POST` | `/boards/:id/columns` | List / create columns | +| `PATCH / DELETE` | `/columns/:id` | Edit / delete column | +| `POST` | `/columns/:id/cards` | Create card | +| `GET` | `/boards/:id/cards` | List cards on a board | +| `GET / PATCH / DELETE` | `/cards/:id` | | +| `PATCH` | `/cards/:id/move` | `{toColumnId, beforeCardId?, afterCardId?}` | +| `POST / DELETE` | `/cards/:id/labels` | Attach / detach (idempotent) | +| `POST` | `/cards/:id/assignments` / `/cards/:id/assignments/:userId` | Assign / unassign | +| `PUT` | `/cards/:id/milestone` | `{milestoneId\|null}` | +| `POST` | `/cards/:id/comments` | Create comment | +| `POST / GET` | `/cards/:id/attachments` | Upload / list attachments | + +### Labels, milestones, templates, custom fields + +| Method | Path | Notes | +|---|---|---| +| `GET / POST` | `/boards/:id/labels` | | +| `PATCH / DELETE` | `/labels/:id` | | +| `GET / POST` | `/boards/:id/milestones` | `?archived=1` includes archived | +| `PATCH / DELETE` | `/milestones/:id` | | +| `GET / POST` | `/boards/:id/card-templates` | | +| `DELETE` | `/card-templates/:id` | | +| `GET / POST` | `/boards/:id/custom-fields` | | +| `PATCH / DELETE` | `/custom-fields/:id` | | + +### Comments, checklists + +| Method | Path | +|---|---| +| `PATCH / DELETE` | `/comments/:id` | +| `GET / POST` | `/cards/:id/checklists` | +| `PATCH / DELETE` | `/checklists/:id` | +| `POST` | `/checklists/:id/items` | +| `PATCH / DELETE` | `/checklist-items/:id` | + +### Automations, webhooks + +| Method | Path | +|---|---| +| `GET / POST` | `/boards/:id/automations` | +| `PATCH / DELETE` | `/automations/:id` | +| `GET / POST` | `/boards/:id/webhooks` | +| `PATCH / DELETE` | `/webhooks/:id` | +| `GET` | `/webhooks/:id/deliveries` | +| `POST` | `/webhooks/deliveries/:id/requeue` | + +### Live + health + +| Method | Path | Notes | +|---|---|---| +| `GET` | `/health` | Liveness/readiness — `{ok, db, redis}` | +| `GET` | `/boards/:id/live` | Server-Sent Events stream of board ticks | +| `GET` | `/boards/:id/activity` | Activity feed (metadata only) | + +### WebSocket relay + +Not an HTTP endpoint — a WebSocket upgrade on `/api/ws` (or directly on port 3001 in dev): + +``` +ws://… /api/ws?t= +``` + +Two room kinds: + +- `card:` — Yjs Y.Doc for the card description +- `board:` — Yjs Awareness for board-level presence + +Every payload is XChaCha20-Poly1305 ciphertext under the Board Key. The relay never decrypts — it just routes by room name. + +--- + +## Rate limits + +Default sliding-window limits (configurable via env): + +| Group | Limit | +|---|---| +| `POST /auth/login` | 10/IP/minute + per-account exponential backoff after 3 failures | +| `POST /auth/register` | 5/IP/hour | +| Card / column / board mutations | 60/user/minute | +| Read-only endpoints | 600/user/minute | + +A breached limit returns `429 Too Many Requests` with a `Retry-After` header. + +--- + +## Webhooks (outbound) + +Configure per board via the UI or `POST /api/boards/:id/webhooks`. Payload shape: + +```json +{ + "verb": "CARD_MOVED", + "boardId": "c...", + "cardId": "c...", + "actorId": "c...", + "timestamp": "2026-05-25T12:00:00.000Z", + "metadata": { "fromColumnId": "c...", "toColumnId": "c..." } +} +``` + +Headers on each delivery: + +``` +X-MSKanban-Event: CARD_MOVED +X-MSKanban-Delivery-Id: +X-MSKanban-Signature: t=,v1= +``` + +Verify the signature with HMAC-SHA256 over `.` using the webhook secret. The `t` value is also in the signature header to prevent replay — reject requests with a `t` more than 5 minutes from now. + +Retries on `5xx` / network error follow an exponential schedule: 30 s, 2 min, 10 min, 1 h, 6 h, 24 h. Anything still failing after 24 h moves to the dead-letter queue. + +--- + +## OpenAPI + +The full spec is at [`docs/api/openapi.yaml`](https://github.com/MSK-Scripts/mskanban/blob/main/docs/api/openapi.yaml) in the project repo. Load it in any OpenAPI viewer (Swagger UI, Stoplight, Insomnia) for an interactive reference. diff --git a/ecosystem/mskanban/faq.md b/ecosystem/mskanban/faq.md new file mode 100644 index 0000000..5165e27 --- /dev/null +++ b/ecosystem/mskanban/faq.md @@ -0,0 +1,168 @@ +--- +title: FAQ +sidebar_position: 7 +--- + +# FAQ + +Common questions about MSKanban — usage, security, deployment. + +--- + +## Usage + +### I forgot my password. What now? + +Use the **recovery key** you saved at registration. The 24-word phrase deterministically re-derives a wrap key that unwraps a backup copy of your User Symmetric Key. Follow the *Recover* link on the login page. + +If you lost both the password **and** the recovery key: your data is unrecoverable. The server cannot help — it never had a usable copy. This is by design (zero-knowledge), not a bug. + +### Can the server admin read my cards? + +No — that's the whole point. Card titles, descriptions, comments, checklists, custom field values, attachments, label names, milestone names and automation rule bodies are all XChaCha20-Poly1305 ciphertext on the server, with keys that never leave a logged-in user's browser. See [Privacy & Security](privacy.md) for the full key hierarchy. + +What the server *does* see: user IDs, board / card / column IDs (random CUIDs), timestamps, positions, due dates, and label colours. That's the metadata it needs for sorting, filtering, the calendar / timeline UI, and analytics. + +### Why can't I search across all my boards? + +Server-side full-text search is impossible on ciphertext, and that's a deliberate trade-off. Client-side search works inside a board you've opened (the cards are decrypted in memory once). A future iteration may add an encrypted search index (think Searchable Symmetric Encryption) but that's not in v0.x. + +### Can I edit a card from my phone? + +Yes — the UI is responsive. PWA installation works on iOS and Android (offline-first with IndexedDB snapshot). The Argon2id KDF takes a couple of seconds longer on a phone than on a laptop; that's normal. + +### What happens to comments / activity when I delete a card? + +The card and its children (comments, checklists, attachments) are hard-deleted via `onDelete: Cascade`. The activity log retains a `CARD_DELETED` row with the card's ID (no plaintext) so the audit trail stays intact — that row points to a no-longer-existing target. + +### My Timeline view shows "Timeline is empty" even though I have due dates set. + +The Timeline only renders cards that have **at least a `dueAt`**. Cards without a due date are intentionally hidden — the footer hint tells you how many. To draw a Gantt-style **bar** (instead of just a diamond on the due day), the card also needs a `startAt` — set it in the card drawer. + +### What's the difference between Timeline and Calendar? + +Calendar is a month grid keyed by `dueAt`. Timeline is a horizontal Gantt grouped by milestone with day / week / month zoom — better for project planning across weeks or months. + +--- + +## Security + +### Is MSKanban audited? + +Internally: yes — threat model, code review, blocking CodeQL in CI, signed containers, SBOMs. Externally: not yet. An external audit + pen-test is planned ahead of the v1.0 release. Until then, treat MSKanban as production-ready for self-host evaluation, not for high-sensitivity third-party data. + +### Why not Bitwarden's crypto verbatim? + +We use the same primitives (Argon2id, XChaCha20-Poly1305, X25519) and the same general shape (KDF → MK → wrapped DEKs). Differences: + +- Workspace + Board keys are per-resource (Bitwarden is per-organisation / per-collection — finer granularity here because boards within a workspace can have different memberships in the future, even though v0.x makes them workspace-wide) +- The recovery flow uses BIP39-style English words for memorability instead of a generated PIN +- We use `argon2-browser` (WASM) rather than a server-roundtrip — the password never reaches the server + +### Why is there no "share via link" feature? + +There is — kind of. Public read-only boards are on the roadmap (USP §5.9). The implementation will put the Board Key in the URL **fragment** (`#key=…`) so the server never sees it (browsers don't send fragments in HTTP requests). Owner has to opt-in explicitly. Not in v0.1. + +### Can I disable end-to-end encryption to make backups simpler? + +No, and that wouldn't simplify backups anyway — a ciphertext DB dump is the same SQL operation as a plaintext one. The benefit of E2EE is the stolen-dump scenario: a thief gets nothing useful, even with full filesystem access. + +### What does the server actually log? + +Per the bundled pino logger config: structured JSON, `LOG_LEVEL=info` by default. Logs include API method + path + response time + status code + `userId` if authenticated + a hashed IP (HMAC over `IP_HASH_SECRET`). Logs **never** include card content, decrypted blobs, raw IPs, or Crypto material. See `src/lib/logger.ts` in the source for the exact format. + +--- + +## Deployment + +### The container starts and immediately exits with `DATABASE_URL not set`. + +Both `DATABASE_URL` and `REDIS_URL` are mandatory. Check your `.env` file (or `EnvironmentFile` for systemd). The example in [`Installation`](installation.md#environment-variables) covers the full list. + +### `502 Bad Gateway` after install. + +Apache is reverse-proxying but the upstream isn't up. Check: + +```bash +# systemd +sudo systemctl status mskanban +sudo journalctl -u mskanban -e --no-pager + +# Docker +sudo docker compose -f /opt/mskanban/docker/docker-compose.prod.yml ps +sudo docker compose -f /opt/mskanban/docker/docker-compose.prod.yml logs app +``` + +Most common cause: the migration step failed (DB user lacks `CREATE` privilege). Re-run with verbose output: + +```bash +sudo -u mskanban pnpm prisma migrate deploy +``` + +### WebSocket relay drops repeatedly / "Live updates paused". + +Apache must have `mod_proxy_wstunnel` enabled and the example vhost includes the necessary `RewriteRule` for the `/api/ws` upgrade. Verify with: + +```bash +apache2ctl -M | grep -E 'proxy|wstunnel' +``` + +If `proxy_wstunnel_module` is missing: + +```bash +sudo a2enmod proxy_wstunnel +sudo systemctl reload apache2 +``` + +### Passkey registration fails silently in the browser. + +`WEBAUTHN_RP_ID` **must** exactly equal the hostname (no scheme, no port, no trailing slash). For `https://kanban.example.com`, set `WEBAUTHN_RP_ID=kanban.example.com`. Mismatch causes the registration to fail without a server-side log (the browser refuses to even send the request). + +### How do I upgrade across pre-1.0 minor versions? + +Read the [release notes](https://github.com/MSK-Scripts/mskanban/releases) first. Pre-1.0 means schema-breaking changes are possible — `pnpm prisma migrate deploy` will tell you what it wants to do before doing it. Take a DB backup before any upgrade. + +### Can I run MSKanban behind nginx / Caddy / Traefik instead of Apache? + +Yes. The reverse proxy just needs to: + +1. Terminate TLS +2. Forward `/` to `http://127.0.0.1:3000` (the Next.js server) +3. Forward `/api/ws` with WebSocket upgrade to `http://127.0.0.1:3001` (the relay) +4. Pass the security headers (`Strict-Transport-Security`, etc.) — or let MSKanban set them and just forward them + +The Apache example in `apache/mskanban.conf.example` is the reference; translating to nginx is a few lines, Caddy is trivial. Recipes for both are on the to-do list. + +### Where does the attachment storage live? + +`STORAGE_DRIVER=local` (the default) writes UUID-named files to `STORAGE_LOCAL_PATH`. Each file is encrypted under a per-attachment file key, which itself is encrypted under the Board Key — server admins reading the directory see meaningless bytes. + +S3-compatible storage is supported but not yet documented; see `src/lib/storage/` in the source if you want to wire it up. + +--- + +## Project + +### Why AGPL-3.0? + +Because we don't want a future cloud vendor to fork MSKanban, host it, and not contribute back. AGPL's network-trigger clause means: if you operate a modified MSKanban as a service for others, you have to publish your modifications. Self-hosting **for yourself** has no such obligation. + +### How do I contribute? + +Read [`CONTRIBUTING.md`](https://github.com/MSK-Scripts/mskanban/blob/main/CONTRIBUTING.md). DCO sign-off (`git commit --signoff`) is mandatory on every commit — no CLA, no copyright assignment. + +### Roadmap? + +Phase markers are tracked in [`CLAUDE.md`](https://github.com/MSK-Scripts/mskanban/blob/main/CLAUDE.md#10-roadmap) of the source repo. Phases 0–10 are ✅ shipped (v0.1.0-beta). Post-beta items live as GitHub issues. + +### I have a feature request. + +Open an [issue](https://github.com/MSK-Scripts/mskanban/issues/new) on GitHub. Please check the existing list first — many ideas are already tracked. + +--- + +## Still stuck? + +- [GitHub issues](https://github.com/MSK-Scripts/mskanban/issues) for bugs and feature requests +- [Discord](https://discord.gg/5hHSBRHvJE) for community questions +- For security findings: see [`SECURITY.md`](https://github.com/MSK-Scripts/mskanban/blob/main/SECURITY.md), **not** the issue tracker diff --git a/ecosystem/mskanban/features.md b/ecosystem/mskanban/features.md new file mode 100644 index 0000000..1ddc042 --- /dev/null +++ b/ecosystem/mskanban/features.md @@ -0,0 +1,237 @@ +--- +title: Features +sidebar_position: 4 +--- + +# Features + +A tour of what MSKanban can do once you're past the [Getting Started](getting-started.md) basics. + +--- + +## Workspaces, boards, columns, cards + +The standard Kanban hierarchy: + +``` +Workspace +└── Board + ├── Column (with optional WIP limit) + │ └── Card + │ ├── Title, Markdown description + │ ├── Labels (coloured, named) + │ ├── Assignees (workspace members) + │ ├── Start date + Due date + │ ├── Milestone (optional) + │ ├── Checklists (multiple per card) + │ ├── Comments (Markdown, @-mentions) + │ ├── Attachments (encrypted blobs) + │ └── Custom field values + └── (Labels, Milestones, Templates, Automation rules, Webhooks) +``` + +Drag-and-drop works between columns and within a column. The keyboard sensor (per WCAG 2.1.1) lets you do the same with `Tab` + `Space` + arrow keys — try it. + +--- + +## Five board views + +All views render from the same locally-decrypted snapshot. Switching tabs is instant. + +### Board (Kanban) +The default. Columns and cards, drag-and-drop, template picker per column. + +### Calendar +Month grid keyed by `dueAt`. Cards without a due date are listed in a side panel. + +### Timeline (Gantt) +Horizontal Gantt-style view grouped by milestone: + +- Cards with **both `startAt` and `dueAt`** → range bar across the day window +- Cards with **only `dueAt`** → diamond marker on the due day +- Cards with **neither** → hidden, counted in the footer hint + +Day / Week / Month zoom toggle, vertical "today" line, `←` / `→` to pan, `+` / `-` to zoom. Pure SVG renderer — no chart library. + +### Table +Sortable / filterable list view. Click a column header to sort; type in the filter box to text-search across decrypted titles and descriptions. + +### Analytics +Six built-in charts: + +| Chart | What it shows | +|---|---| +| **Cycle Time** | Histogram of "time from first move out of Backlog to entering Done" per card | +| **Lead Time** | Histogram of "time from card creation to entering Done" | +| **Cumulative Flow Diagram (CFD)** | Stacked-area of column counts over time — reveals bottlenecks | +| **Throughput** | Cards per week entering Done | +| **Aging WIP** | Indicator of cards sitting too long in their current column | +| **Burn-Down per milestone** | Scope vs. remaining work vs. ideal line for a milestone window | + +All computations run client-side from the decrypted activity feed. The server hosts metadata-only event rows; it doesn't know what's in the cards being counted. + +--- + +## Labels + +Each label has: + +- A **colour** (server-visible — needed for filter / sort UI) +- An **encrypted name** (server stores ciphertext only) + +Attaching a label to a card is a server-side row in `CardLabel` referencing both IDs. The IDs are opaque, so a server admin learns "card X has label Y" but not what Y *means*. + +--- + +## Milestones + +Milestones group cards into deliverables with an optional date window: + +- **Server-visible**: board association, `startAt`, `endAt`, `archived` flag (needed by Timeline + Burn-Down) +- **Encrypted**: name, description + +A card is linked to at most one milestone (`Card.milestoneId`, nullable). Milestone deletion is `onDelete: SetNull` — cards survive, the link drops. + +Used by Timeline (group rows) and Analytics → Burn-Down (scope baseline). + +--- + +## Card Templates + +Save a card (title + description + checklists) as a board-wide template, then create new cards from it with one click. Useful for repeated work patterns (bug report, retro item, onboarding checklist, release checklist). + +Templates are encrypted under the Board Key just like cards. + +--- + +## Custom Fields + +Per-board configurable fields. Six types: + +- `text` — free-form string (encrypted) +- `number` — JSON-encoded number (encrypted) +- `date` — ISO date string (encrypted) +- `url` — URL (encrypted) +- `checkbox` — true/false (encrypted) +- `dropdown` — pick one of an admin-defined option list (the *list* lives encrypted in the field definition; the *value* is encrypted too) + +Custom fields show in the card drawer below the description. + +--- + +## Automation Rules + +Declarative `{when, do}` rules per board. See [ADR 0010](https://github.com/MSK-Scripts/mskanban/blob/main/docs/architecture/0010-automation-engine.md) for the architecture. + +**v1 triggers (wired)**: +- `card_created` +- `card_moved` + +**v1 actions** (all idempotent — running twice converges to the same state): +- `set_label` +- `assign_member` +- `move_to_column` + +**Coming next**: `card_due_reached` (needs a BullMQ scheduler), `post_comment` + `emit_webhook` (need the Redis SETNX idempotency claim), `append_checklist`, plus drawer-side emitters for `card_label_added` and `comment_added`. + +The rule body lives in `enc_rule` (server can't read it). A small plaintext trigger envelope (`trigger_type` + `trigger_meta`) is mirrored separately, validated against a strict whitelist on write, so the future scheduler can route ticks without breaking E2EE. + +--- + +## Real-Time Collaboration + +Two layers, both over the same encrypted WebSocket relay: + +### Card description sync (Yjs CRDT) +Open the same card in two browser sessions — type in one, the other reflects within ~50 ms. Concurrent edits are CRDT-merged so neither side overwrites the other. + +### Board presence (Yjs Awareness) +The board header shows an avatar stack of everyone currently on the board (max 5 + "+N more"). When somebody has a card drawer open, small coloured dots appear on that card in the kanban view. + +Awareness payload is **`{userId, color, viewing?}` only** — no email, no card title, no description text. Colours are deterministic HSL hashes of `userId`, stable across reloads. + +--- + +## Offline-First PWA + +MSKanban registers a Service Worker and persists decrypted board snapshots to IndexedDB. Effect: + +- A board you've opened before loads **instantly** on a flaky connection (cache hit, then refresh in the background) +- Card description drafts are persisted locally — closing the drawer doesn't lose unsaved work +- A board opens even fully offline, against the last snapshot + +The IndexedDB store is keyed per user-id so account-switching on the same browser doesn't leak the previous user's data. On logout the store is wiped. + +--- + +## Authentication + +| Method | Server stores | +|---|---| +| Password | Argon2id-hashed AuthHash (m=64MB, t=3, p=4). The Master Key derived from the password never reaches the server. | +| TOTP | Secret encrypted with `SERVER_ENCRYPTION_KEY` (not the user's password — so it survives a recovery-key reset) | +| WebAuthn / Passkeys | Public key + credential ID. The private half lives in the authenticator. | +| Recovery Key | An alternate `encRecoveryBlob` of the User Symmetric Key, encrypted under a key derived from the recovery phrase. | + +Brute-force protection: per-IP + per-user exponential backoff, account lockout after 10 failed attempts (15-minute window). + +Sessions are HttpOnly + Secure + SameSite=Strict cookies, 30-minute idle timeout, sliding-window refresh. WebSocket connections use single-use tickets fetched via the cookie session. + +--- + +## Webhooks + +Per-board configurable. Events: card created / moved / deleted, comment added, assignment, label added, … + +Each delivery is: + +- HMAC-SHA256-signed with a per-webhook secret (returned **once** at creation — never re-displayed) +- Retried with exponential backoff (up to 24 h) +- Routed through a **dead-letter queue** if all retries fail; the UI lets you inspect failures and one-click requeue +- SSRF-guarded (no private IP ranges, no link-local) + +Payloads carry **metadata only** — `{verb, cardId, actorId, timestamp}`. They do **not** contain card titles or descriptions. To get content, the receiver has to be a workspace member and decrypt it through the regular API. + +--- + +## Import / Export + +### Import +- **Trello JSON** — boards, lists, cards (with descriptions, labels, due dates) +- **CSV** — flexible columns, mapped to title/description/dueAt/columnName + +### Export +- **Native JSON** — complete board snapshot, restorable +- **Markdown** — human-readable, one file per board + +:::warning +**Exports leave the encrypted boundary.** A JSON export is a plaintext copy of everything the user could already see. The UI warns and asks for confirmation before downloading — keep the file secure. +::: + +--- + +## API + +A REST API mirrors most UI actions. Useful for CLI tools, scripts, CI/CD integrations. + +See the [API reference](api.md). + +--- + +## Accessibility + +Built to WCAG 2.1 AA: + +- Full keyboard navigation including drag-and-drop alternative +- Screen-reader optimisations: `aria-*`, semantic HTML, live regions for board updates +- `prefers-reduced-motion` respected +- Contrast ratios verified +- Focus rings on every interactive element + +--- + +## Internationalisation + +UI ships with **German** and **English**, switched via a cookie. Adding a locale is a `messages/.json` file plus `next-intl` registration — see the project README for the pattern. + +The crypto layer is locale-independent. diff --git a/ecosystem/mskanban/getting-started.md b/ecosystem/mskanban/getting-started.md new file mode 100644 index 0000000..4453902 --- /dev/null +++ b/ecosystem/mskanban/getting-started.md @@ -0,0 +1,158 @@ +--- +title: Getting Started +sidebar_position: 3 +--- + +# Getting Started + +This page walks you through the **first ten minutes** with MSKanban: creating an account, understanding what makes the zero-knowledge model different, and getting to a working board. + +If you haven't installed the server yet, see [Installation](installation.md) first. + +--- + +## The zero-knowledge model in one minute + +MSKanban is **not** a normal SaaS Kanban. + +When you sign up: + +1. Your browser derives a **Master Key** from your password using Argon2id (the same KDF Bitwarden uses). +2. The Master Key never leaves your browser. The server stores a separate **Auth Hash** derived from it, which can verify your password but cannot decrypt anything. +3. The Master Key encrypts a per-user Symmetric Key + an X25519 keypair. Those in turn encrypt your Workspace Keys, which encrypt your Board Keys, which finally encrypt card content. +4. When you log out or close the browser, the Master Key is wiped. To get back in you have to re-enter your password — there is **no other path** to your data. + +The practical consequence: **a forgotten password = lost data**, unless you saved the recovery key. There is no "email me a reset link" button because the server has nothing to send you that would let it back in. + +This is the same trade-off Bitwarden, Standard Notes and CryptPad make. It's not a bug. + +--- + +## 1. Create the first account + +Open your instance (e.g. `https://kanban.your-domain.com`) and click **Register**. You need: + +- An email address (used for login notifications and the username — never published) +- A password of **at least 12 characters** (checked against the HaveIBeenPwned k-anonymity API client-side; pwned passwords are blocked) + +After hitting "Create account", the browser runs Argon2id locally — this takes 1–3 seconds depending on your CPU. That's intentional: it's the same work an attacker would have to do for each password guess. + +--- + +## 2. Save your Recovery Key + +Immediately after registration you are shown a **24-word recovery phrase** (BIP39-style, English wordlist). + +:::danger Write it down NOW. +This is the only fallback for a forgotten password. Lose it and your data is gone. The server can't help — it cannot recover it for you because it never had it. + +**Do** store it in a password manager, on paper in a safe, or both. +**Don't** screenshot it into your phone gallery. Don't email it to yourself. Don't paste it into a chat. +::: + +The recovery key encrypts an alternate copy of your User Symmetric Key on the server. If you ever forget your password, you go to the **Recover** page, type the 24 words, set a new password, and you're back in. The server only learns that someone successfully recovered — not the wordlist itself, which gets re-derived from the user input client-side. + +--- + +## 3. Turn on 2FA (recommended) + +In **Account → Security** you'll find two options: + +- **TOTP** (Google Authenticator, Aegis, 1Password, …) — scan the QR code, enter the 6-digit code to confirm +- **WebAuthn / Passkeys** — works with any FIDO2 authenticator: hardware key, Touch ID, Windows Hello, mobile Passkey + +Both can be enabled at the same time. The TOTP secret is stored on the server encrypted with `SERVER_ENCRYPTION_KEY` (not derived from your password), so it survives a password reset via recovery key. + +:::tip Passkeys are the better default. +A YubiKey or platform authenticator means even a leaked password + recovery key won't get an attacker in. Set one up the first time you log in from a desktop browser. +::: + +--- + +## 4. Create your first workspace + board + +After the first login you land on an empty **Workspaces** screen. + +1. Click **+ New workspace** and give it a name (e.g. *Personal*, *Acme Co*). The name is encrypted under your User Symmetric Key — the server never sees the plaintext. +2. Inside the workspace, click **+ New board**. The board comes pre-populated with three columns: *Backlog*, *In progress*, *Done*. +3. Drag-and-drop a placeholder card around to convince yourself it works. Then click **+ Add a card** to create your first real one. + +Card titles and descriptions are encrypted under the **Board Key**, which itself is encrypted under your Workspace Key, which itself is encrypted under your User Key. See [Privacy & Security](privacy.md) for the full key hierarchy. + +--- + +## 5. Invite teammates to a workspace + +A workspace can have multiple members. Adding one is a four-step crypto dance under the hood, but the UI hides it: + +1. In **Workspace settings → Members** click **+ Add member** and enter their email. +2. The server confirms the user exists and returns their X25519 **public key**. +3. Your browser uses that public key to **seal** a copy of the Workspace Key (NaCl Sealed Box — anonymous sender, only the holder of the matching private key can open it). +4. The sealed copy goes back to the server, which stores it on the new `WorkspaceMember` row. + +Now the new member can open the workspace: their browser fetches the sealed blob, opens it with their User Private Key (decrypted from their own Master Key), and adds the Workspace Key to their in-memory key bundle. + +The server **never** sees the Workspace Key in the clear. It only sees ciphertext addressed to each member individually. + +--- + +## 6. Explore the views + +Each board has five views — switch between them via the tab strip near the top right: + +| View | When to use | +|---|---| +| **Board** (default) | Classic Kanban — drag cards between columns | +| **Calendar** | Cards with due dates as a month grid | +| **Timeline** | Gantt-style — cards as bars / diamonds, grouped by milestone | +| **Table** | Sortable / filterable list, good for bulk edits | +| **Analytics** | Cycle Time, Cumulative Flow, Throughput, Burn-Down | + +All five views render from the same locally-decrypted dataset — switching tabs is instant and does not re-fetch from the server. + +See [Features](features.md) for what each view does in detail. + +--- + +## 7. Set up an automation rule + +Scroll past the board to the **Automation rules** panel. + +A simple first rule: + +- **Trigger:** "When a card is moved" +- **Only when moved to column:** `Done` +- **Action:** "attach a label" → pick (or create first) a label like `closed` + +Click **Create rule**. Now move a card to *Done* — the label appears automatically. + +The rule body lives in `enc_rule` on the server (server can't read it). Only a small "trigger envelope" (`trigger_type=card_moved`, `trigger_meta={toColumnId:"col_done"}`) is server-visible, and even that is validated against a strict whitelist on write. See the [automation ADR](https://github.com/MSK-Scripts/mskanban/blob/main/docs/architecture/0010-automation-engine.md) for the architecture. + +--- + +## 8. Real-time check + +Open the same board in a second browser (or incognito window with a different account that's a member of the same workspace). + +You should see: + +- An **avatar stack** in the board header showing who else is on the board +- **Coloured dots** on a card when somebody else has its drawer open +- Card-description edits **sync live** between the two windows via Yjs CRDT + +All of this happens over an encrypted WebSocket relay — the relay sees only ciphertext, even for presence updates. + +--- + +## What's next + +- [Features](features.md) — deeper look at views, milestones, automation, presence +- [REST API](api.md) — for scripts and integrations +- [Privacy & Security](privacy.md) — full key hierarchy, threat model, GDPR notes +- [FAQ](faq.md) — common questions ("I lost my recovery key, what now?", etc.) + +--- + +:::info +Stuck on something? Open an [issue](https://github.com/MSK-Scripts/mskanban/issues) or join the [Discord](https://discord.gg/5hHSBRHvJE). +::: diff --git a/ecosystem/mskanban/index.md b/ecosystem/mskanban/index.md new file mode 100644 index 0000000..bc704aa --- /dev/null +++ b/ecosystem/mskanban/index.md @@ -0,0 +1,94 @@ +--- +title: Overview +sidebar_position: 1 +--- + +# MSKanban + +**A self-hostable, zero-knowledge Kanban board** — part of the MSK ecosystem alongside [MSK Paste](../msk-paste/index.md) and [MSK Shortener](../msk-shortener/index.md). + +- **Source code:** [github.com/MSK-Scripts/mskanban](https://github.com/MSK-Scripts/mskanban) +- **Container image:** [`ghcr.io/musiker15/mskanban`](https://github.com/musiker15/mskanban/pkgs/container/mskanban) +- **License:** AGPL-3.0-or-later +- **Current release:** `v0.1.0-beta` (pre-1.0 — see the [release notes](https://github.com/MSK-Scripts/mskanban/releases)) + +:::warning Pre-1.0 +The crypto envelope, database schema and public API may still change without a major-version bump. Each change is documented in the project [`CHANGELOG`](https://github.com/MSK-Scripts/mskanban/blob/main/CHANGELOG.md). **Do not put production data on it yet.** +::: + +--- + +## What is MSKanban? + +MSKanban is a Trello-style Kanban board that **encrypts your content on your device before it reaches the server**. Even a fully compromised server — database dump, malicious admin, anything short of running code on your machine — cannot read your card titles, descriptions, comments, checklists, custom-field values, or attachments. The server only sees opaque ciphertext and the metadata it strictly needs to route requests (user IDs, timestamps, positions). + +The differentiator is **zero-knowledge**: a Trello-style UX with the "server can't read your data" guarantee of Bitwarden or Standard Notes. + +--- + +## Why MSKanban? + +| Feature | Trello | Jira Cloud | Planka | Wekan | **MSKanban** | +|---|:-:|:-:|:-:|:-:|:-:| +| Self-hostable | ❌ | ❌ | ✅ | ✅ | ✅ | +| **Zero-knowledge E2EE** | ❌ | ❌ | ❌ | ❌ | ✅ | +| WebAuthn / Passkeys | ⚠️ | ⚠️ | ❌ | ❌ | ✅ | +| Real-time sync (CRDT) | ❌ | ❌ | ⚠️ | ❌ | ✅ | +| Offline-first PWA | ⚠️ | ❌ | ❌ | ⚠️ | ✅ | +| Built-in webhooks with DLQ | ✅ | ✅ | ❌ | ❌ | ✅ | +| GDPR-ready by design | ❌ | ⚠️ | ⚠️ | ✅ | ✅ | +| WCAG 2.1 AA | ⚠️ | ⚠️ | ❌ | ⚠️ | ✅ | +| Signed container + SBOM | ❌ | ❌ | ❌ | ❌ | ✅ | + +--- + +## Feature highlights + +- **Workspaces, boards, columns, cards** — the usual Kanban primitives, all with E2EE-encrypted titles, descriptions, comments, checklists, and attachments +- **Labels** with colour + encrypted name; **assignees**; **due dates + start dates** +- **Milestones** for grouping cards with a date window (drives burn-down analytics) +- **Five board views**: Kanban / Calendar / Timeline (Gantt) / Table / Analytics +- **Analytics**: Cycle Time, Lead Time, Cumulative Flow Diagram, Throughput, Aging WIP, Burn-Down per milestone +- **Automation rules** ("when a card moves to *Done*, attach label *closed*") — fully E2EE, [ADR 0010](https://github.com/MSK-Scripts/mskanban/blob/main/docs/architecture/0010-automation-engine.md) +- **Real-time** via Yjs CRDTs over an encrypted WebSocket relay (card descriptions sync live across users; board-level presence shows who else is on the board) +- **Offline-first PWA** with IndexedDB snapshot cache +- **Authentication**: Argon2id passwords, TOTP 2FA, WebAuthn / Passkeys +- **Recovery key** instead of classic password reset (zero-knowledge means we *can't* email you a reset link — see [ADR 0004](https://github.com/MSK-Scripts/mskanban/blob/main/docs/architecture/0004-recovery-key.md)) +- **Webhooks** with HMAC-SHA256 signatures, persistent retry queue, dead-letter queue +- **Import**: Trello JSON, plain CSV. **Export**: native JSON, Markdown +- **Hardened deployment**: signed container (cosign keyless OIDC), CycloneDX + SPDX SBOMs, CSP / HSTS / COOP+COEP+CORP headers, systemd hardening profile + +--- + +## Tech stack + +| Layer | Technology | +|---|---| +| Framework | Next.js 16 (App Router, React 19) | +| Language | TypeScript (strict, `noUncheckedIndexedAccess`) | +| Database | MariaDB 10.11+ via Prisma 7 (driver-adapter) | +| Real-time | Yjs CRDT + custom encrypted WebSocket relay | +| Cache + queue | Redis 7+ (rate-limiting, sessions, BullMQ) | +| Styling | Tailwind CSS 4 + MSK design tokens | +| Crypto | libsodium-wrappers (XChaCha20-Poly1305 + X25519) + argon2-browser (Argon2id KDF) | +| Validation | Zod 4 | +| Web server | Apache2 (reverse proxy) | +| Process manager | systemd or Docker | +| CI/CD | GitHub Actions | + +--- + +## Where to go next + +- [Installation](installation.md) — Self-host MSKanban on your own Debian/Ubuntu server (Docker or bare-metal) +- [Getting Started](getting-started.md) — First login, recovery key, creating your first board +- [Features](features.md) — Tour of boards, views, automation, real-time, presence +- [REST API](api.md) — Programmatic access for scripts and integrations +- [Privacy & Security](privacy.md) — What the server can and cannot see, threat model +- [FAQ](faq.md) — Common questions and troubleshooting + +--- + +:::info +Questions or feedback? Join the [Discord](https://discord.gg/5hHSBRHvJE) or open an issue on [GitHub](https://github.com/MSK-Scripts/mskanban/issues). +::: diff --git a/ecosystem/mskanban/installation.md b/ecosystem/mskanban/installation.md new file mode 100644 index 0000000..c5c260a --- /dev/null +++ b/ecosystem/mskanban/installation.md @@ -0,0 +1,300 @@ +--- +title: Installation +sidebar_position: 2 +--- + +# Self-Hosting MSKanban + +This guide walks you through installing MSKanban on your own Debian or Ubuntu server. MSKanban is **only** meant to be self-hosted — there is no managed SaaS instance, and the demo at `demo.mskanban.app` wipes its database nightly. + +Two shapes are supported: + +1. **Docker on a single host**, behind a TLS-terminating reverse proxy (recommended). +2. **Bare-metal Node.js on Debian**, behind Apache2 (mirrors the maintainer's reference environment). + +Both shapes share the same minimum dependencies. + +--- + +## Requirements + +| Component | Minimum Version | +|---|---| +| OS | Debian 11+ / Ubuntu 22.04+ | +| Node.js | 22.x LTS (bare-metal only — container ships its own) | +| MariaDB | 10.11+ (MySQL 8 compatible) | +| Redis | 7+ | +| Apache | 2.4+ (`mod_proxy`, `mod_proxy_http`, `mod_proxy_wstunnel`, `mod_headers`, `mod_ssl`, `mod_rewrite`, `mod_http2`) | +| Domain | A subdomain pointing to your server (e.g. `kanban.example.com`) | +| SSL | Let's Encrypt via certbot | + +:::tip +Why **Redis** as a hard requirement? It backs rate limiting, session storage (for the WebSocket relay's connection tickets), and the BullMQ webhook delivery queue. MSKanban will not start without it. +::: + +--- + +## Pre-flight checklist + +- [ ] DNS A/AAAA record for the chosen hostname points to the server. +- [ ] Apache modules listed above are enabled. +- [ ] `AUTH_SECRET` and `SERVER_ENCRYPTION_KEY` generated with `openssl rand -base64 32` (independent values). +- [ ] Dedicated unprivileged MariaDB user (no `GRANT` permission). +- [ ] Backup target reachable (gpg-encrypted offsite recommended). +- [ ] Firewall: only `:443` (and `:80` for ACME) open to the internet. + +--- + +## Option 1: Docker (recommended) + +### 1.1 Clone the repo and prepare secrets + +```bash +sudo git clone https://github.com/MSK-Scripts/mskanban.git /opt/mskanban +cd /opt/mskanban + +# Copy the example env and edit +sudo cp .env.example .env +sudo chmod 600 .env +sudo nano .env +``` + +Fill the env file (see [Environment variables](#environment-variables) below). + +### 1.2 Bring the stack up + +```bash +cd /opt/mskanban +sudo docker compose -f docker/docker-compose.prod.yml up -d +``` + +The compose file binds the app to `127.0.0.1:3000` and the WebSocket relay to `127.0.0.1:3001` — both **localhost-only**. A reverse proxy on the host terminates TLS and proxies to those ports. + +### 1.3 Configure Apache as the TLS-terminating reverse proxy + +```bash +sudo cp apache/mskanban.conf.example /etc/apache2/sites-available/mskanban.conf +sudo sed -i 's/kanban\.example\.com/kanban.your-domain.com/g' \ + /etc/apache2/sites-available/mskanban.conf + +sudo a2enmod ssl headers proxy proxy_http proxy_wstunnel rewrite http2 deflate +sudo a2ensite mskanban +sudo apache2ctl configtest +sudo systemctl reload apache2 +``` + +### 1.4 Issue an SSL certificate + +```bash +sudo apt install -y certbot python3-certbot-apache +sudo certbot --apache -d kanban.your-domain.com +``` + +### 1.5 Verify + +```bash +# Health endpoint +curl https://kanban.your-domain.com/api/health +# → {"ok":true,...} + +# Container status +sudo docker compose -f docker/docker-compose.prod.yml ps +``` + +--- + +## Option 2: Bare-metal on Debian + Apache + +### 2.1 Install runtime dependencies + +```bash +# Node.js 22 LTS +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install -y nodejs + +# pnpm +sudo corepack enable +sudo corepack prepare pnpm@latest --activate + +# MariaDB + Redis +sudo apt install -y mariadb-server redis-server +sudo mysql_secure_installation +``` + +### 2.2 Database setup + +```sql +CREATE DATABASE mskanban CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'mskanban'@'localhost' IDENTIFIED BY 'change_me_strong_password'; +GRANT SELECT, INSERT, UPDATE, DELETE, INDEX, ALTER, CREATE, REFERENCES + ON mskanban.* TO 'mskanban'@'localhost'; +-- No GRANT permission, no DROP — the migration tool needs CREATE/ALTER but +-- not the ability to escalate privileges. +FLUSH PRIVILEGES; +``` + +### 2.3 Application user, directories, env + +```bash +sudo useradd --system --create-home --shell /usr/sbin/nologin mskanban +sudo install -d -o mskanban -g mskanban -m 0750 /opt/mskanban /var/lib/mskanban /etc/mskanban + +sudo git clone https://github.com/MSK-Scripts/mskanban.git /opt/mskanban +sudo chown -R mskanban:mskanban /opt/mskanban + +sudo install -o root -g mskanban -m 0640 /dev/null /etc/mskanban/env +sudo nano /etc/mskanban/env +``` + +Fill `/etc/mskanban/env` with the values from the [Environment variables](#environment-variables) section. + +### 2.4 Install, migrate, build + +```bash +cd /opt/mskanban +sudo -u mskanban pnpm install --frozen-lockfile --prod +sudo -u mskanban pnpm prisma migrate deploy +sudo -u mskanban pnpm build +``` + +### 2.5 systemd unit + +```bash +sudo cp docs/deployment/mskanban.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now mskanban +sudo systemctl status mskanban +``` + +The unit ships hardened: `NoNewPrivileges`, `ProtectSystem=strict`, capability set empty, syscall filter, network restricted to localhost. + +### 2.6 Apache vhost + TLS + +```bash +sudo cp apache/mskanban.conf.example /etc/apache2/sites-available/mskanban.conf +sudo sed -i 's/kanban\.example\.com/kanban.your-domain.com/g' \ + /etc/apache2/sites-available/mskanban.conf +sudo a2enmod ssl headers proxy proxy_http proxy_wstunnel rewrite http2 deflate +sudo a2ensite mskanban +sudo systemctl reload apache2 + +sudo apt install -y certbot python3-certbot-apache +sudo certbot --apache -d kanban.your-domain.com +``` + +--- + +## Environment variables + +```bash +# ─── App ───────────────────────────────────────────────────────────── +NODE_ENV=production +PORT=3000 +NEXT_PUBLIC_APP_URL=https://kanban.your-domain.com + +# ─── Database (MariaDB) ────────────────────────────────────────────── +DATABASE_URL=mysql://mskanban:change_me_strong_password@127.0.0.1:3306/mskanban + +# ─── Redis ─────────────────────────────────────────────────────────── +REDIS_URL=redis://127.0.0.1:6379 + +# ─── Auth + Crypto ─────────────────────────────────────────────────── +# Generate each with: openssl rand -base64 32 +AUTH_SECRET=change_me_with_openssl_rand_base64_32 +SERVER_ENCRYPTION_KEY=change_me_with_openssl_rand_base64_32 + +# ─── WebAuthn / Passkeys ───────────────────────────────────────────── +WEBAUTHN_RP_ID=kanban.your-domain.com +WEBAUTHN_RP_NAME=MSKanban +WEBAUTHN_RP_ORIGIN=https://kanban.your-domain.com + +# ─── Mail (optional, used for login notifications) ─────────────────── +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=no-reply@your-domain.com + +# ─── Attachments ───────────────────────────────────────────────────── +STORAGE_DRIVER=local +STORAGE_LOCAL_PATH=/var/lib/mskanban/storage +ATTACHMENT_MAX_BYTES=26214400 # 25 MB + +# ─── Logging ───────────────────────────────────────────────────────── +LOG_LEVEL=info +``` + +:::warning +- `AUTH_SECRET` and `SERVER_ENCRYPTION_KEY` are **independent** values. Reusing one for the other defeats the purpose of having both. +- The `WEBAUTHN_RP_ID` **must** exactly match your hostname (no scheme, no port). Mismatch breaks Passkey registration silently. +- **Never** commit `.env` or `/etc/mskanban/env` to git. Both have restrictive permissions baked into the setup steps above. +::: + +--- + +## Updating + +### Docker + +```bash +cd /opt/mskanban +sudo docker compose -f docker/docker-compose.prod.yml pull +sudo docker compose -f docker/docker-compose.prod.yml up -d +``` + +### Bare-metal + +```bash +sudo -u mskanban git -C /opt/mskanban fetch --tags +sudo -u mskanban git -C /opt/mskanban checkout v +sudo -u mskanban pnpm install --frozen-lockfile --prod +sudo -u mskanban pnpm prisma migrate deploy +sudo -u mskanban pnpm build +sudo systemctl restart mskanban +``` + +:::tip +Read the [release notes](https://github.com/MSK-Scripts/mskanban/releases) before upgrading across a minor boundary. Pre-1.0 means breaking schema changes are possible — `pnpm prisma migrate deploy` will refuse to apply a migration that drops a column you still depend on. +::: + +--- + +## Verifying the installation + +```bash +# Health endpoint +curl https://kanban.your-domain.com/api/health + +# Service status (bare-metal) +sudo systemctl status mskanban +sudo journalctl -u mskanban -f --since "5 minutes ago" + +# Container status (Docker) +sudo docker compose -f /opt/mskanban/docker/docker-compose.prod.yml ps +sudo docker compose -f /opt/mskanban/docker/docker-compose.prod.yml logs -f app +``` + +You should see `{"ok":true,"db":"up","redis":"up"}` from the health endpoint. + +--- + +## Backups + +A daily MariaDB dump is the bare minimum. The crypto envelope means even a stolen backup is useless without the user's password, but you still want it for disaster recovery: + +```bash +sudo crontab -e +``` + +```cron +# Daily DB dump at 03:00, retained for 14 days, gpg-encrypted offsite +0 3 * * * /opt/mskanban/scripts/backup.sh +``` + +For attachments, sync `/var/lib/mskanban/storage` to your offsite target as well — attachment ciphertext is on disk, not in the DB. + +--- + +## Troubleshooting + +See the [FAQ](faq.md) for common issues such as `502 Bad Gateway`, WebSocket relay drops, Passkey registration failures, and recovery-key loss. diff --git a/ecosystem/mskanban/privacy.md b/ecosystem/mskanban/privacy.md new file mode 100644 index 0000000..0d0b8bf --- /dev/null +++ b/ecosystem/mskanban/privacy.md @@ -0,0 +1,201 @@ +--- +title: Privacy & Security +sidebar_position: 6 +--- + +# Privacy & Security + +MSKanban's defining property is **zero-knowledge** end-to-end encryption: a fully compromised server — DB dump, malicious admin, anything short of running code on your device — cannot read your content. + +This page documents exactly what is and is not protected, the key hierarchy, and the threat model. It's intentionally detailed; if you're evaluating MSKanban for sensitive work, this is what you need to read. + +The authoritative reference is the project's [threat model](https://github.com/MSK-Scripts/mskanban/blob/main/docs/threat-model.md) and [ADR 0003](https://github.com/MSK-Scripts/mskanban/blob/main/docs/architecture/0003-zero-knowledge-e2ee.md). This page is the human-friendly tour. + +--- + +## What the server sees + +| Plaintext on server | Reason | +|---|---| +| User ID, email | Login routing, member lookup | +| Workspace / Board / Card / Column IDs | Random CUIDs, no plaintext meaning | +| Card `position`, `dueAt`, `startAt`, `archived` | Sorting, calendar / timeline UI, server-side cleanup | +| `Card.milestoneId`, `CardLabel.labelId` | Foreign-key references for filter / sort UI | +| Label **colour** (hex) | Used for filter / sort UI | +| Milestone `startAt`, `endAt`, `archived` | Burn-Down + Timeline scoping | +| `AutomationRule.trigger_type`, `trigger_meta` | Plaintext trigger envelope for future scheduler (see [ADR 0010](https://github.com/MSK-Scripts/mskanban/blob/main/docs/architecture/0010-automation-engine.md)) — strict whitelist on write | +| Activity log: verb + actorId + targetId + ISO timestamp | Activity feed, webhooks, notifications | +| Hashed IPs (HMAC over `IP_HASH_SECRET`) | Brute-force protection, never raw | +| HTTP session timestamps + UA string | Sliding-window session expiry | + +| Ciphertext only (server cannot read) | Encrypted under | +|---|---| +| Workspace name + metadata | Workspace Key | +| Board name + metadata | Workspace Key (binding `board:`) | +| Column name | Board Key | +| Card title + description + custom field values | Board Key (binding `card:`) | +| Comments | Board Key (binding `card:`) | +| Checklists + checklist items | Board Key | +| Attachment filename, MIME type, blob bytes | Board Key + per-attachment file key | +| Label name | Board Key | +| Milestone name + description | Board Key | +| Automation rule body (name + conditions + actions) | Board Key | +| Card template body | Board Key | +| Webhook secret | `SERVER_ENCRYPTION_KEY` (server-side, not user-derived) | +| TOTP secret | `SERVER_ENCRYPTION_KEY` | + +--- + +## Key hierarchy + +``` +Password (user types) + │ + ▼ Argon2id (m=64 MiB, t=3, p=4, per-user salt) +Master Key (MK) ─────► never sent to server + │ + ├─► Auth Hash (Argon2id of MK with a different salt) ──► sent on login + │ + ▼ wraps +User Symmetric Key (USK) + User X25519 keypair + │ + ▼ USK wraps +Workspace Key (one per workspace) + │ + ▼ Workspace Key wraps +Board Key (one per board) + │ + ▼ Board Key wraps (XChaCha20-Poly1305 AEAD) +Card content, comments, attachments, labels, milestones, automation rules… +``` + +Sharing a workspace with a new member is a NaCl Sealed Box of the Workspace Key, addressed to the invitee's X25519 public key. The server stores the sealed copy per-member; only the invitee's private key can open it. + +### Primitives + +| Where | Algorithm | +|---|---| +| Password KDF | Argon2id, m=64 MiB, t=3, p=4 — implemented via `argon2-browser` (WASM) | +| Symmetric AEAD | XChaCha20-Poly1305 (libsodium-wrappers) | +| Member key exchange | X25519 + crypto_box (sealed boxes) | +| Random | Web Crypto API `crypto.getRandomValues()` exclusively | +| HMAC (webhook signing, IP hashing) | HMAC-SHA256 | +| Server-side at-rest key (`SERVER_ENCRYPTION_KEY`) | AES-256-GCM | + +Algorithms are **not** user-configurable. Crypto agility for a future migration is handled at the envelope level (`v1..` — the `v1` prefix is the negotiation hook). + +--- + +## Recovery Key (read this once) + +Zero-knowledge means **no email-me-a-reset-link**. The server cannot regenerate access; it never had a usable copy. + +The fallback is a 24-word recovery phrase shown **exactly once** at registration. It deterministically derives an alternate wrap key that the server uses to encrypt a separate copy of the User Symmetric Key. On recovery the user enters the 24 words, the wrap key is re-derived client-side, the alternate USK copy is unwrapped, and the user sets a fresh password. + +If the user loses **both** the password and the recovery phrase, the data is unrecoverable. This is by design. + +ADR: [`0004-recovery-key.md`](https://github.com/MSK-Scripts/mskanban/blob/main/docs/architecture/0004-recovery-key.md). + +--- + +## Master Key persistence + +ADR 0009: the Master Key lives in **memory** + a **`sessionStorage`** envelope. + +- Inside one tab, the MK is held in a module-private variable +- A separate wrap key (32 bytes, Web Crypto) is also held in memory only +- The MK is encrypted with the wrap key (XChaCha20-Poly1305, AAD `mskanban|mk-session|v1`) and the **encrypted** blob is stored in `sessionStorage` +- On page reload the wrap key is gone, the blob in sessionStorage is unrecoverable, the user must sign in again. So: one password prompt per *browser session*, not per F5. +- New tabs cannot share the MK (each tab is its own JS Realm + its own sessionStorage) +- `wipe()` on logout zeroes both memory and sessionStorage + +A future Service Worker mode could enable cross-tab continuity but is intentionally not in scope today — [ADR 0009](https://github.com/MSK-Scripts/mskanban/blob/main/docs/architecture/0009-mk-session-persistence.md) explains the trade-off. + +--- + +## Transport security + +Configured both in Next.js middleware **and** in the Apache vhost (defence in depth): + +``` +Strict-Transport-Security: max-age=63072000; includeSubDomains; preload +Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; + style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' wss://; + font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +Referrer-Policy: no-referrer +Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=() +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Resource-Policy: same-origin +``` + +The `'unsafe-inline'` for styles is on a tracked TODO to replace with nonces. `'wasm-unsafe-eval'` is required for libsodium's WASM build. + +--- + +## Threat model — quick summary + +The [full STRIDE analysis](https://github.com/MSK-Scripts/mskanban/blob/main/docs/threat-model.md) is in the source repo. In short: + +| Threat | Mitigation | +|---|---| +| Stolen DB dump | All sensitive fields ciphertext-only; AEAD keys never leave the client | +| Malicious admin | Cannot decrypt without the user's password / recovery key | +| Stolen session cookie | HttpOnly + Secure + SameSite=Strict + 30-min idle timeout | +| Brute-force login | Per-IP + per-user exponential backoff, account lockout after 10 fails | +| XSS | React default-escaping + DOMPurify for Markdown + strict CSP | +| CSRF | Double-submit cookie + SameSite=Strict | +| SQL injection | Prisma parametrised queries exclusively; no `$queryRawUnsafe` | +| Mass assignment | Zod schema on every endpoint | +| SSRF via webhooks | Private IP range block + DNS rebinding check | +| Path traversal in attachments | UUID storage keys, never user-input filenames | +| Replay of webhook payloads | HMAC-SHA256 with timestamp + 5-min window | +| ReDoS | All regexes `safe-regex` checked | +| Supply chain | Signed container (cosign keyless OIDC), CycloneDX + SPDX SBOMs, `pnpm audit` in CI | + +What **isn't** mitigated (and isn't claimed to be): + +- Code execution **on the user's device**. A malicious browser extension or compromised endpoint can read the MK from memory — that's outside the threat model. Zero-knowledge protects against the server, not the client. +- Traffic analysis. The server sees you exist, you log in at certain times, you touch certain Card IDs. It cannot see the content of those Cards, but the access pattern itself is metadata. +- The server learning who is on a board at a given moment (Yjs Awareness routes a `board:` room, the relay sees that fact). Awareness payloads themselves are encrypted, but the *existence* of a connection is observable to the operator. + +--- + +## GDPR + +| Article | Implementation | +|---|---| +| Art. 15 (Right of Access) | `GET /api/me/export` — full JSON dump of everything the user can decrypt + all metadata about them | +| Art. 17 (Right to Erasure) | `DELETE /api/me` — crypto-shreds keys immediately (data instantly unreadable), hard-deletes rows after a 30-day grace window | +| Art. 20 (Data Portability) | JSON export is restorable into another MSKanban instance; the Markdown export is human-readable; a Trello-compatible JSON export is on the roadmap | + +The hosted demo instance shows a cookie banner; self-hosted installs **don't** need one because nothing is tracked. Set `FEATURE_DEMO_MODE=false` (the default) to hide it. + +--- + +## Reporting a vulnerability + +Read the [`SECURITY.md`](https://github.com/MSK-Scripts/mskanban/blob/main/SECURITY.md) in the source repo: + +- Coordinated disclosure, 90-day window +- PGP key published on the security page +- Hall of Fame for valid reports + +Please do **not** open a public GitHub issue for security findings. + +--- + +## Audit status + +| Category | Status | +|---|---| +| Internal threat model + code review | ✅ documented in `docs/threat-model.md` | +| Static analysis (CodeQL) | ✅ blocking in CI | +| Container signing (cosign keyless OIDC) | ✅ all `ghcr.io/musiker15/mskanban` tags | +| SBOM (CycloneDX + SPDX) | ✅ attached to every release | +| External audit | ⚪ not yet — planned for the v1.0 cycle | +| Penetration test | ⚪ not yet — planned for the v1.0 cycle | + +Pre-1.0: treat MSKanban as production-ready for self-host evaluation, not for processing high-sensitivity third-party data, until the external audit lands.