From 142a65a22f1096fd04a29367753061cf13a222ff Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 11:47:59 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Phase=209=20documentation=20roundup=20?= =?UTF-8?q?=E2=80=94=20close=20encryption-shaped=20doc=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five documentation surfaces gained encryption awareness in this sweep. Before this commit, the only place anyone could learn about the at-rest encryption layer or the zero-knowledge opt-in was the internal DATA_LAYER_AUDIT.md. New contributors and self-hosters would never discover one of the most important features of the product just by reading the standard onboarding docs. apps/docs/src/content/docs/architecture/security.mdx (NEW) ---------------------------------------------------------- First-class user-facing security page in the Starlight site, slotted into the Architecture sidebar between Authentication and Backend. Sections: - What's encrypted (overview table of 27 modules + the intentional plaintext carve-outs) - Standard mode flow with ASCII diagram - "What Mana CAN see" trust statements per mode - Zero-knowledge mode setup walkthrough (Steps component) - Unlock flow on a new device - Recovery code rotation - Deployment requirements (the loud MANA_AUTH_KEK warning) - Audit trail action vocabulary - Threat model summary table - Implementation file references with paths services/mana-auth/CLAUDE.md ---------------------------- New "Encryption Vault" section under Key Endpoints, listing all 7 routes (status, init, key, rotate, recovery-wrap GET+DELETE, zero-knowledge) with their HTTP method, path, error codes, and a description. Mentions the three CHECK constraints + RLS + audit table. Points readers at DATA_LAYER_AUDIT.md and the new security.mdx for the deep dive. Environment Variables block gains MANA_AUTH_KEK with a multi-line comment explaining the openssl rand command + dev fallback warning. apps/mana/CLAUDE.md ------------------- Full rewrite. The existing file was from the Supabase era and described things like @supabase/ssr, safeGetSession(), and a five-table schema with users + organizations + teams that doesn't exist any more. Replaced with the unified-app architecture: - Module system layout (collections.ts / queries.ts / stores/) - Mana Auth (Better Auth + EdDSA JWT) instead of Supabase - Local-first data layer with the full pipeline diagram - At-rest encryption section with the "when writing module code that touches sensitive fields" 4-step guide - Updated routing structure (no more separate /organizations, /teams routes) - Module store pattern code example - Reference document table at the bottom pointing at the audit, the new security.mdx, and the auth doc Root CLAUDE.md -------------- New "At-Rest Encryption (Phase 1–9)" subsection under the Local-First Architecture section. Two-mode trust summary table, production requirement for MANA_AUTH_KEK with the openssl command, the "when writing module code" 4-step guide, and a reference table. New contributors reading the root CLAUDE.md from top to bottom now hit encryption naturally as part of the data layer discussion. .env.macmini.example -------------------- MANA_AUTH_KEK was missing from the production env example entirely — the macmini deployment would silently boot on the 32-zero-byte dev fallback if you copied this file. Added with a multi-paragraph comment covering: how to generate, why it's required, how to store securely (Docker secrets / KMS / Vault), and the rotation caveat. apps/docs/src/content/docs/deployment/self-hosting.mdx ------------------------------------------------------ Two changes: 1. Added MANA_AUTH_KEK to the mana-auth service block in the Compose example with an inline comment pointing at the new section below. 2. New "Encryption Vault Setup" H2 section with subsections: - Generating a KEK (with a fake example value labelled DO NOT USE — generate your own) - Securing the KEK (Docker secrets, KMS, systemd LoadCredential, anti-patterns) - "What if I lose the KEK?" — explains the data is unrecoverable by design and mitigation via zero-knowledge mode opt-in - KEK rotation — calls out the missing background re-wrap job as a known limitation apps/docs/astro.config.mjs -------------------------- Added "Security & Encryption" entry to the Architecture sidebar between Authentication and Backend so the new page is reachable from the docs nav. Astro check: 0 errors, 0 warnings, 0 hints across 4 .astro files. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.macmini.example | 16 + CLAUDE.md | 25 ++ apps/docs/astro.config.mjs | 1 + .../content/docs/architecture/security.mdx | 204 ++++++++++++ .../content/docs/deployment/self-hosting.mdx | 48 +++ apps/mana/CLAUDE.md | 294 ++++++++++-------- services/mana-auth/CLAUDE.md | 27 ++ 7 files changed, 483 insertions(+), 132 deletions(-) create mode 100644 apps/docs/src/content/docs/architecture/security.mdx diff --git a/.env.macmini.example b/.env.macmini.example index 3c15c2013..69a075427 100644 --- a/.env.macmini.example +++ b/.env.macmini.example @@ -32,6 +32,22 @@ JWT_SECRET=your-jwt-secret-here JWT_PUBLIC_KEY= JWT_PRIVATE_KEY= +# ============================================ +# Encryption Vault Key Encryption Key (KEK) — REQUIRED +# ============================================ +# Wraps every user's master key in auth.encryption_vaults. +# Generate with: openssl rand -base64 32 +# +# Without a real value, mana-auth boots with a 32-zero-byte fallback +# and prints a loud warning every startup. Production must set this. +# Treat it like a database root password — store as a Docker secret, +# KMS-injected env var, or Vault-served value. +# +# Rotation requires planned downtime today (no background re-wrap job +# yet). The kek_id column on encryption_vaults is reserved for the +# future migration path. +MANA_AUTH_KEK= + # ============================================ # Supabase (optional, for legacy features) # ============================================ diff --git a/CLAUDE.md b/CLAUDE.md index d8575c217..d49d8da8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -655,6 +655,31 @@ The Mana unified app uses a **single IndexedDB** (`mana`) containing all 120+ co Individual apps in `apps/*/apps/web/` still use `@mana/local-store` with per-app IndexedDB databases (`mana-{appId}`). When users first open the unified Mana app, `legacy-migration.ts` migrates data from these old DBs into the unified DB. +### At-Rest Encryption (Phase 1–9) + +User-typed content in **27 tables** is encrypted with **AES-GCM-256** before it touches IndexedDB. The master key lives in mana-auth (KEK-wrapped) and is fetched on login. Two trust modes: + +- **Standard mode** (default): server holds the user's master key wrapped with `MANA_AUTH_KEK` (env-provided). Mana operators with KEK access could theoretically decrypt; protected against all realistic threats except court-ordered disclosure. +- **Zero-Knowledge mode** (opt-in via Settings → Sicherheit): user generates a 32-byte recovery code locally, the server NULLs out the KEK wrap. Mana is then **computationally incapable** of decrypting user data — losing the recovery code means losing the data, by design. + +**Production requirement:** `MANA_AUTH_KEK` must be set on the mana-auth service to a base64-encoded 32-byte value (`openssl rand -base64 32`). The dev fallback is 32 zero bytes and prints a loud warning at startup. + +**When writing module code that touches sensitive fields:** + +1. Add the table to `apps/mana/apps/web/src/lib/data/crypto/registry.ts` with the field allowlist +2. Wrap writes with `await encryptRecord(tableName, record)` before `table.add()` / `table.update()` +3. Wrap reads with `decryptRecords(tableName, visible)` after the Dexie query, before the type converter +4. Default to **encrypt** for user-typed text; default to **plaintext** for IDs / timestamps / sort keys / enum discriminators + +**Reference docs:** + +| File | Purpose | +|------|---------| +| `apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md` | Complete sprint history, rollout phases, threat model, backlog | +| `apps/docs/src/content/docs/architecture/security.mdx` | User-facing walkthrough + threat model table | +| `services/mana-auth/sql/002_encryption_vaults.sql` | Vault schema + RLS policies | +| `services/mana-auth/sql/003_recovery_wrap.sql` | Phase 9 recovery columns + CHECK constraints | + ### Dev Commands (Local-First Stack) ```bash diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index e0da9e726..d8d4ab5d6 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -46,6 +46,7 @@ export default defineConfig({ items: [ { label: 'Overview', slug: 'architecture/overview' }, { label: 'Authentication', slug: 'architecture/authentication' }, + { label: 'Security & Encryption', slug: 'architecture/security' }, { label: 'Backend (NestJS)', slug: 'architecture/backend' }, { label: 'Web (SvelteKit)', slug: 'architecture/web' }, { label: 'Mobile (Expo)', slug: 'architecture/mobile' }, diff --git a/apps/docs/src/content/docs/architecture/security.mdx b/apps/docs/src/content/docs/architecture/security.mdx new file mode 100644 index 000000000..31c056a93 --- /dev/null +++ b/apps/docs/src/content/docs/architecture/security.mdx @@ -0,0 +1,204 @@ +--- +title: Security & Encryption +description: Trust model, at-rest encryption, and the optional zero-knowledge mode for Mana user data. +--- + +import { Aside, Tabs, TabItem, Steps } from '@astrojs/starlight/components'; + +# Security & Encryption + +Mana encrypts user-typed content with **AES-GCM-256** before it touches IndexedDB or the sync server. The user has two trust modes to choose from: + +| Mode | Default | What the server can decrypt | +|------|---------|----------------------------| +| **Standard** | ✅ Yes | The user's master key, via the server-side KEK | +| **Zero-Knowledge** | Opt-in | Nothing — the recovery code lives only with the user | + +## What's encrypted + +**27 tables** ship with at-rest encryption enabled. The full list is in [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/mana-monorepo/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md), but the highlights: + +| Module | Fields | +|--------|--------| +| Chat | `messages.messageText`, `conversations.title`, chat templates | +| Notes | `title`, `content` | +| Dreams | `title`, `content`, `transcript`, `interpretation`, `location` | +| Memoro | `title`, `intro`, `transcript` (the largest plaintext blobs in the app) | +| Contacts | 16 PII fields (firstName, lastName, email, phone, mobile, birthday, address, social) | +| Cycles | `notes`, `mood` (GDPR Art. 9 sensitive personal data) | +| Finance | `transactions.description`, `transactions.note` | +| Cards | `front`, `back`, deck name + description | +| Todo | `tasks.title`, `description`, `subtasks`, `metadata` | +| Calendar | `events.title`, `description`, `location` + the cross-module `timeBlocks` hub | +| Picture | `images.prompt`, `negativePrompt`, board name + description | +| Storage | `files.name`, `originalName` | +| Music | `songs.title`, playlist name + description | +| Events | `socialEvents.title/description/location`, guest contact details | + +What's intentionally **plaintext** for structural reasons: + +- IDs, foreign keys, timestamps +- Sort/filter keys (e.g. `tasks.dueDate`, `songs.artist`) +- Public-redirect lookup keys (`uload.links.shortCode` and `originalUrl`) +- Published social-event content (decrypted by design when the user shares an RSVP link) + +## Standard Mode (default) + +``` +┌─────────────┐ Login ┌──────────────┐ +│ Browser │────────────>│ mana-auth │ +│ │ │ :3001 │ +└─────────────┘ └──────────────┘ + │ │ + │ GET /encryption-vault/key│ + │ ──────────────────────────>│ + │ │ 1. Load wrapped MK from + │ │ auth.encryption_vaults + │ │ 2. Unwrap with KEK from + │ │ MANA_AUTH_KEK env var + │ │ 3. Return raw 32-byte MK + │ <──────────────────────────│ + │ + ▼ + importMasterKey() → CryptoKey (non-extractable) + │ + ▼ + MemoryKeyProvider holds the key for the session + │ + ▼ + encryptRecord() / decryptRecord() per Dexie write/read +``` + +The master key never crosses the browser process boundary except as base64 over HTTPS during the initial fetch. Once imported, it's a **non-extractable CryptoKey** — even malicious JavaScript with a reference to it cannot read its raw bytes. + +### What Mana CAN see in Standard Mode + +- ❌ Never the contents of encrypted fields without actively unwrapping the KEK +- ⚠️ Theoretically: a Mana operator with KEK access could unwrap the master key and decrypt user data. Protected against all realistic threats except a court-ordered disclosure. + +This is the same trust model as 1Password's "I trust the company" tier or Signal's local-only key. + +## Zero-Knowledge Mode (opt-in) + +Users who want **provable** confidentiality (not just "we promise") can opt into zero-knowledge mode in **Settings → Sicherheit**. + + + +1. **Generate a recovery code.** The browser generates 32 random bytes via Web Crypto, derives an AES wrap key via HKDF-SHA256, and seals the user's master key locally. The wrapped blob is sent to the server. The 32-byte secret itself **never leaves the browser**. + +2. **Back up the recovery code.** The UI displays the formatted code (`1A2B-3C4D-...`, 79 characters). The user copies it into their password manager. They have to type it back to confirm — we don't move forward until the confirmation matches. + +3. **Activate.** The server NULLs out the KEK-wrapped master key and sets `zero_knowledge=true` on the vault row. From this moment on, **the server is computationally incapable of decrypting the user's data**. + + + +### Unlock flow on a new device + +When a zero-knowledge user signs in on a new device: + +``` +1. Login → JWT + ↓ +2. Browser fetches /encryption-vault/key + ↓ +3. Server returns { requiresRecoveryCode: true, recoveryWrappedMk, recoveryIv } + ↓ +4. Browser shows the RecoveryCodeUnlockModal + ↓ +5. User pastes the code from their password manager + ↓ +6. Browser unwraps the master key locally with HKDF + AES-GCM + ↓ +7. App boots with decrypted data +``` + +If the user loses the recovery code, **the data is unrecoverable**. Mana cannot help — that's the design. The trade-off is the entire point of zero-knowledge. + +### What Mana CAN see in Zero-Knowledge Mode + +- ❌ Never the contents of encrypted fields +- ❌ Never the master key (the server has no usable copy) +- ❌ Never the recovery code (it lives only with the user) +- ✅ Structural metadata: number of records, timestamps, foreign keys, which modules are active + +A database CHECK constraint (`encryption_vaults_zk_consistency`) enforces the "ZK active ⇒ recovery wrap exists" invariant at the schema level, so the server cannot accidentally lock a user out. + +## Recovery code rotation + +Users can rotate their recovery code without disabling zero-knowledge mode. The settings page shows a "🔁 Recovery-Code rotieren" button when ZK is active. The flow: + +1. Browser uses the cached master key bytes (stashed during the recovery-code unlock earlier in the session) +2. Generates a fresh 32-byte secret + new HKDF wrap key +3. Seals the same master key with the new wrap key +4. Posts the new wrap to `/recovery-wrap` (replaces the previous row) +5. Displays the new code; the old one is now permanently invalid + +If the user is in standard mode, rotation re-fetches the master key from the server (same path as the initial setup). + +## Deployment requirements + + + +### Key rotation + +The KEK and the per-user master keys can rotate independently: + +- **MK rotation** (`POST /encryption-vault/rotate`): mints a fresh master key, wipes the existing wrap. The old MK is lost — caller is responsible for re-encrypting any data sealed with it. Use case: suspected device compromise. +- **KEK rotation**: handled by deploying a new `MANA_AUTH_KEK` value with a new `kek_id`. Old vault rows keep their original `kek_id` until a background re-wrap job migrates them. (The migration job is a future addition — for now KEK rotation requires planned downtime.) + +## Audit trail + +Every vault access is recorded in `auth.encryption_vault_audit`: + +| Action | When | +|--------|------| +| `init` | First-time vault creation | +| `fetch` | Each `GET /key` call (the hot path) | +| `failed_fetch` | Any 4xx/5xx on the fetch path | +| `rotate` | Master key rotation | +| `recovery_set` | New recovery wrap stored | +| `recovery_clear` | Recovery wrap removed | +| `zk_enable` | Zero-knowledge mode activated | +| `zk_disable` | Zero-knowledge mode deactivated | + +Each row carries the IP address, user-agent, HTTP status code, and a free-form context string. Used for security investigations and compliance reporting. + +## Threat model summary + +| Threat | Standard | Zero-Knowledge | +|--------|----------|----------------| +| Browser-local malware reading IndexedDB | ✅ Protected (encrypted blobs) | ✅ Protected | +| Stolen device with no screen lock | ✅ Protected (key not persisted) | ✅ Protected | +| Database leak (encrypted_vaults dump) | ✅ Protected (KEK is in env, not DB) | ✅ Protected | +| Mana operator with full DB access | ⚠️ Could decrypt with KEK | ✅ Cannot decrypt | +| Mana operator with full DB + KEK access | ⚠️ Could decrypt | ✅ Cannot decrypt (no usable wrap) | +| Court order against Mana | ⚠️ Could be compelled to decrypt | ✅ Mana physically cannot comply | +| User loses recovery code | n/a | ❌ Data lost | +| User loses password but vault is in ZK mode | Recovery via password reset | ❌ Data lost (vault is keyed to recovery code) | + +## Implementation references + +For the architectural deep dive, code locations, and the complete rollout history (Phases 1–9 + the backlog sweep), see [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/mana-monorepo/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md). + +Key files: + +| File | Role | +|------|------| +| `apps/mana/apps/web/src/lib/data/crypto/aes.ts` | AES-GCM-256 wrap/unwrap primitives | +| `apps/mana/apps/web/src/lib/data/crypto/registry.ts` | Allowlist of which fields on which tables get encrypted | +| `apps/mana/apps/web/src/lib/data/crypto/recovery.ts` | Recovery code generation, format/parse, HKDF wrap | +| `apps/mana/apps/web/src/lib/data/crypto/vault-client.ts` | Browser-side vault client + zero-knowledge state machine | +| `apps/mana/apps/web/src/lib/components/RecoveryCodeUnlockModal.svelte` | Lock-screen modal for the ZK unlock flow | +| `apps/mana/apps/web/src/routes/(app)/settings/security/+page.svelte` | Settings UI for setup, rotation, disable | +| `services/mana-auth/src/services/encryption-vault/index.ts` | Server-side vault service | +| `services/mana-auth/src/services/encryption-vault/kek.ts` | KEK loader + master-key wrap helpers | +| `services/mana-auth/sql/002_encryption_vaults.sql` | Vault table + RLS policies | +| `services/mana-auth/sql/003_recovery_wrap.sql` | Recovery wrap columns + CHECK constraints | diff --git a/apps/docs/src/content/docs/deployment/self-hosting.mdx b/apps/docs/src/content/docs/deployment/self-hosting.mdx index 3bc6e0e3f..43bfe7f3b 100644 --- a/apps/docs/src/content/docs/deployment/self-hosting.mdx +++ b/apps/docs/src/content/docs/deployment/self-hosting.mdx @@ -120,6 +120,10 @@ services: REDIS_PASSWORD: ${REDIS_PASSWORD} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY} + # REQUIRED: encryption-vault Key Encryption Key. + # Generate with: openssl rand -base64 32 + # See "Encryption Vault Setup" below. + MANA_AUTH_KEK: ${MANA_AUTH_KEK} depends_on: postgres: condition: service_healthy @@ -160,6 +164,50 @@ echo "JWT_PRIVATE_KEY=$(cat private.pem | base64 -w 0)" echo "JWT_PUBLIC_KEY=$(cat public.pem | base64 -w 0)" ``` +## Encryption Vault Setup + +mana-auth ships with a per-user encryption vault that wraps each user's master key with a service-wide **Key Encryption Key (KEK)**. The KEK is loaded from the `MANA_AUTH_KEK` environment variable on boot — **it is required in production** and refusing to set it leaves you running on a 32-zero-byte dev fallback that prints a loud warning every startup. + +### Generating a KEK + +```bash +# 32 random bytes, base64-encoded +openssl rand -base64 32 + +# Example output (DO NOT use this — generate your own!) +# 4n8jzXq2K9pL5mR7tY1wE3uI6oP0sD8fG2hJ4kL6nM8= +``` + +Add the result to your `.env`: + +```env +MANA_AUTH_KEK=4n8jzXq2K9pL5mR7tY1wE3uI6oP0sD8fG2hJ4kL6nM8= +``` + +### Securing the KEK + +Treat the KEK like a database root password — anyone with both the KEK and the database can decrypt every user's master key. Do not commit it to git, do not log it, do not paste it into chat. + +Recommended storage: + +- **Docker Secrets**: `secrets:` block in compose, mounted into the container at `/run/secrets/mana_auth_kek` +- **HashiCorp Vault / AWS Secrets Manager / Google Secret Manager**: inject at boot via init container or sidecar +- **systemd `LoadCredential=`**: when running mana-auth as a systemd service + +Avoid plaintext `.env` files on production hosts where possible. + +### What if I lose the KEK? + +Every user's master key in `auth.encryption_vaults` becomes unrecoverable. The wrapped data on disk is mathematically opaque without the KEK. There is no backup path on the server side — by design. + +The mitigation: users who care about that risk should opt into **Zero-Knowledge Mode** in **Settings → Sicherheit**, which moves the wrap from the server-side KEK to a client-held recovery code. After that, the KEK loss only affects new user sign-ups, not existing accounts. + +### KEK rotation + +KEK rotation today requires planned downtime — there is no background re-wrap job yet. The `kek_id` column on `auth.encryption_vaults` is reserved for the future migration path. Track issue [#TODO] for when this lands. + +For now, treat the KEK as long-lived and rotate JWT signing keys (which are independent) on the regular schedule instead. + ## Reverse Proxy Setup ### Nginx diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md index 6d719f5fc..73257f69e 100644 --- a/apps/mana/CLAUDE.md +++ b/apps/mana/CLAUDE.md @@ -4,82 +4,118 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Mana Apps is a monorepo containing multiple applications that share a unified authentication system powered by Supabase. The repository includes: +**Mana** is the unified web application serving 27+ product modules (todo, calendar, contacts, chat, notes, dreams, memoro, cards, picture, presi, music, storage, …) under one SvelteKit app, one IndexedDB, one auth session, and one deployment at **mana.how**. -- **Web App** (`apps/web`): SvelteKit-based web application -- **Mobile App** (`apps/mobile`): React Native (Expo) application for iOS, Android, and web -- **Landing** (`apps/landing`): Landing page directory (currently minimal/empty) +- **Web App** (`apps/web`): SvelteKit 2 + Svelte 5 unified app — the main surface +- **Mobile App** (`apps/mobile`): Expo / React Native (work-in-progress, separate codebase) +- **Landing** (`apps/landing`): Astro static landing page deployed to Cloudflare Pages ## Architecture -### Multi-App Ecosystem +### Unified Module System -This is a multi-tenant system where a single authentication backend supports multiple branded applications (Memoro, Cards, Storyteller, Mana). Each app shares the same user database but can present different branding and features. +All modules share one SvelteKit build. Each module lives in `apps/web/src/lib/modules/{name}/` and registers itself via a `module.config.ts` file. The data layer uses a single Dexie IndexedDB (`mana`) containing all 120+ collections from every module — table names that collide across modules are prefixed (e.g. `todoProjects`, `cardDecks`, `presiDecks`). -**Key concept**: App configuration is centralized in `apps/web/src/lib/config/apps.ts` and defines branding, features, and routing for each application in the ecosystem. +Module state lives in three files per module: -### Authentication & Session Management +| File | Role | +|------|------| +| `collections.ts` | Dexie table references + (sometimes) seed data | +| `queries.ts` | Read-side: Dexie liveQuery hooks, type converters, pure helpers for `$derived` | +| `stores/*.svelte.ts` | Write-side: mutation methods, no reads (those go through queries.ts) | -Both web and mobile apps use Supabase for authentication with different approaches: +### Authentication -**Web App (SvelteKit)**: +**Mana Auth** is the central authentication service (`services/mana-auth/`, port 3001), built on Better Auth + Hono + Bun. The web app talks to it via the shared `@mana/shared-auth` client. -- Server-side authentication using `@supabase/ssr` -- Two-hook middleware system in `apps/web/src/hooks.server.ts`: - - `supabase` hook: Creates Supabase client per request with cookie management - - `authGuard` hook: Validates JWT, protects `(app)` routes, redirects based on auth state -- Safe session validation: Uses `safeGetSession()` which validates JWT via `getUser()` instead of just reading from cookies -- Route groups: `(auth)` for login/register, `(app)` for protected dashboard pages +- **Token format**: EdDSA JWT with minimal claims (`sub`, `email`, `role`, `sid`, `tier`) +- **Session storage**: Cookies (`*.mana.how` domain) + JWT in memory +- **Route protection**: `(app)` group is auth-gated via the `AuthGate` component in the layout +- **Cross-app SSO**: Same Mana Auth session works across all `*.mana.how` apps +- **Access tiers**: `guest` < `public` < `beta` < `alpha` < `founder` — apps can require a minimum tier via `mana-apps.ts` -**Mobile App (Expo)**: +The legacy Supabase integration was removed. Anything mentioning `@supabase/ssr`, `safeGetSession()`, or `event.locals.supabase` is a leftover from a much earlier iteration and should be deleted on sight. -- Client-side authentication using `@supabase/supabase-js` -- Custom memory storage implementation (`apps/mobile/utils/memoryStorage.ts`) for session persistence -- `AuthProvider` component in `apps/mobile/app/_layout.tsx` handles auth state and navigation -- Platform-specific configuration (web build disables realtime to avoid import issues) +### Data Layer (Local-First) -### Database Schema +The app reads and writes to IndexedDB **first**, then syncs to the server in the background via the **mana-sync** Go service (port 3050). -Key tables (inferred from queries): +Architecture diagram: -- `users`: User profiles linked via `auth_id` to Supabase Auth users -- `user_roles`: Junction table linking users to organizations (with role information) -- `organizations`: Organization entities -- `teams`: Team entities within organizations -- `team_members`: Junction table linking users to teams +``` +User action (e.g. tasksStore.createTask) + │ + ▼ +Module store builds the LocalRecord + │ + ▼ +encryptRecord(tableName, record) ← Phase 4–9 encryption layer + │ + ▼ +table.add(encryptedRecord) ← Dexie write + │ + ▼ +Dexie hooks (database.ts): + - stamp userId + - stamp __fieldTimestamps per field + - record into _pendingChanges + - record into _activity + │ + ▼ +Sync engine (sync.ts) — debounced 1s + - groups changes by appId + - POSTs to mana-sync + │ + ▼ +mana-sync → PostgreSQL with field-level LWW + RLS + │ + ▼ +Other clients pull via SSE / polling + │ + ▼ +applyServerChanges → Dexie hooks (suppressed) → liveQuery → decryptRecord → UI +``` + +For the deep dive — sync engine, retry/backoff, quota recovery, telemetry, RLS, encryption rollout — read **`apps/web/src/lib/data/DATA_LAYER_AUDIT.md`**. This is the single most important file for understanding how the app works under the hood. + +### At-Rest Encryption + +User-typed content in **27 tables** is encrypted with **AES-GCM-256** before it touches IndexedDB. The master key lives in `mana-auth` (KEK-wrapped) and is fetched on login. + +Two trust modes: + +| Mode | Default | What Mana can decrypt | +|------|---------|----------------------| +| Standard | ✅ Yes | The user's master key, via the server-side KEK | +| Zero-Knowledge | Opt-in (Settings → Sicherheit) | Nothing — recovery code lives only with the user | + +**When writing module code that touches sensitive fields:** + +1. Add the table to `apps/web/src/lib/data/crypto/registry.ts` with the field allowlist +2. Wrap writes: `await encryptRecord(tableName, record)` before `table.add()` / `table.update()` +3. Wrap reads: `decryptRecords(tableName, visible)` after the Dexie query, before the type converter +4. The Dexie hook in `database.ts` does NOT auto-encrypt — every store does it explicitly. This is by design (Web Crypto is async, hooks are sync). + +For new sensitive fields, default to **encrypt**. For new structural fields (IDs, timestamps, enums, sort/filter keys), default to **plaintext**. + +User-facing docs at `apps/docs/src/content/docs/architecture/security.mdx`. ### Routing Structure -**Web App** (SvelteKit file-based routing): - ``` -routes/ -├── (auth)/ # Public authentication pages -│ ├── login/ -│ └── register/ -├── (app)/ # Protected application pages -│ ├── dashboard/ -│ ├── organizations/ +apps/web/src/routes/ +├── (auth)/ # Public auth pages (login, register, recovery) +├── (app)/ # Auth-gated app surface — 27+ module routes +│ ├── dashboard/ # Customizable widget grid │ ├── settings/ -│ └── teams/ -└── api/ # API endpoints +│ │ └── security/ # Vault status + recovery code + ZK opt-in +│ ├── todo/ # …and many more module routes +│ └── … +├── workbench/ # Multi-pane interface (ListView + DetailView per module) +└── api/ # SvelteKit API endpoints (rare; most data is local-first) ``` -**Mobile App** (Expo Router): - -``` -app/ -├── (drawer)/ # Main drawer navigation -│ ├── (tabs)/ # Nested tab navigation -│ ├── organizations/ -│ ├── teams/ -│ ├── settings/ -│ └── apps/ -├── auth/ # Auth-related screens -└── login # Login screen -``` - -### Path Aliases (Web App) +### Path Aliases Defined in `apps/web/svelte.config.js`: @@ -102,13 +138,12 @@ pnpm dev # Start dev server on port 5173 # Building pnpm build # Build for production -pnpm preview # Preview production build on port 4173 +pnpm preview # Preview production build # Code Quality pnpm check # Type-check with svelte-check -pnpm check:watch # Type-check in watch mode -pnpm lint # Check formatting and lint -pnpm format # Format code with Prettier +pnpm lint # Format check + ESLint +pnpm format # Prettier write # Testing pnpm test # Run Vitest unit tests @@ -116,113 +151,108 @@ pnpm test:ui # Run Vitest with UI pnpm test:e2e # Run Playwright E2E tests ``` +For full local-dev setup (Mana Auth + mana-sync + web together), use the root-level `dev:*:full` commands. See `docs/LOCAL_DEVELOPMENT.md` and the root `CLAUDE.md`. + ### Mobile App (apps/mobile) -```bash -cd apps/mobile - -# Development -npm start # Start Expo dev server with dev client -npm run ios # Run on iOS simulator -npm run android # Run on Android emulator -npm run web # Run web version on port 19006 - -# Building (EAS) -npm run build:dev # Build development client -npm run build:preview # Build preview/internal distribution -npm run build:prod # Build production (auto-increment version) - -# Code Quality -npm run lint # Lint and check formatting -npm run format # Fix linting and format code - -# Setup -npm run prebuild # Generate native projects -``` +The mobile app is currently lower-priority than the web app and may lag behind in features. Standard Expo commands apply (`pnpm start`, `pnpm ios`, `pnpm android`, EAS builds for production). ## Environment Configuration -Both apps require Supabase credentials. Copy the `.env.example` files and configure: +Generated automatically from the root `.env.development` via `pnpm setup:env`. The relevant variables for the web app: -**Web App** (`apps/web/.env`): - -``` -PUBLIC_SUPABASE_URL=your_supabase_url -PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key -MIDDLEWARE_URL=https://mana-middleware-111768794939.europe-west3.run.app +```env +PUBLIC_MANA_AUTH_URL=http://localhost:3001 +PUBLIC_MANA_SYNC_URL=http://localhost:3050 ``` -**Mobile App** (`apps/mobile/.env`): - -``` -EXPO_PUBLIC_SUPABASE_URL=your_supabase_url -EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key -``` +The web app does NOT read or store any database credentials directly — all server-side data goes through Mana Auth + mana-sync. See the root `CLAUDE.md` for the full env-var rundown. ## Technology Stack ### Web App -- **Framework**: SvelteKit 2 with Svelte 5 -- **Styling**: TailwindCSS with PostCSS -- **Database**: Supabase (PostgreSQL) -- **Auth**: Supabase Auth with SSR -- **Testing**: Vitest (unit), Playwright (E2E) +- **Framework**: SvelteKit 2 + Svelte 5 (runes mode throughout) +- **Styling**: TailwindCSS +- **Auth**: Mana Auth (Better Auth + EdDSA JWT) via `@mana/shared-auth` +- **Data**: Local-first with Dexie.js + mana-sync (Go) backend +- **Encryption**: AES-GCM-256 via Web Crypto, server-wrapped MK with optional zero-knowledge mode +- **Testing**: Vitest (unit + integration with fake-indexeddb), Playwright (E2E) - **Build**: Vite ### Mobile App -- **Framework**: Expo 52 with React Native 0.76 -- **Routing**: Expo Router 4 (file-based) -- **Styling**: NativeWind (TailwindCSS for React Native) -- **Navigation**: React Navigation (drawer, tabs) -- **Database**: Supabase +- **Framework**: Expo + React Native +- **Routing**: Expo Router (file-based) +- **Styling**: NativeWind - **Build**: EAS Build -- **Platforms**: iOS, Android, Web ## Important Patterns -### Server-Side Data Loading (Web) +### Module Store Pattern -Use `+page.server.ts` files for server-side data fetching with automatic auth context: +Every module has a mutation-only store that handles writes + a queries file that handles reads. The store NEVER reads from Dexie via `await table.toArray()` for UI rendering — that's the queries file's job (via liveQuery hooks). The store only reads when it needs to mutate based on existing state (e.g. toggle, increment). ```typescript -export const load: PageServerLoad = async ({ locals: { supabase, session } }) => { - if (!session) { - throw redirect(307, '/login'); - } +// modules/todo/stores/tasks.svelte.ts +import { taskTable } from '../collections'; +import { encryptRecord } from '$lib/data/crypto'; +import { toTask } from '../queries'; - const { data } = await supabase.from('table_name').select('*').eq('user_id', session.user.id); - - return { data }; +export const tasksStore = { + async createTask(input: {...}) { + const newLocal: LocalTask = { ...input, id: crypto.randomUUID() }; + const plaintextSnapshot = toTask({ ...newLocal }); + await encryptRecord('tasks', newLocal); + await taskTable.add(newLocal); + return plaintextSnapshot; + }, + // ... }; ``` -### Supabase Client Access +```typescript +// modules/todo/queries.ts +export function useAllTasks() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('tasks').orderBy('order').toArray(); + const visible = locals.filter((t) => !t.deletedAt); + const decrypted = await decryptRecords('tasks', visible); + return decrypted.map(toTask); + }, [] as Task[]); +} +``` -**Web**: Access via `event.locals.supabase` in server code, or use helper functions in `$server/supabase.ts`: +### Svelte 5 Runes Mode -- `getUser(event)`: Get current user -- `getSession(event)`: Get current session -- `requireAuth(event)`: Require auth or throw error -- `getSupabaseServerClient(event)`: Get the Supabase client +All routes and components use Svelte 5 runes: -**Mobile**: Import directly from `~/utils/supabase.ts` +```typescript +// CORRECT +let count = $state(0); +let doubled = $derived(count * 2); +$effect(() => { + console.log(count); +}); + +// WRONG (legacy Svelte 3/4) +let count = 0; +$: doubled = count * 2; +``` + +### Auth Access + +Auth state lives in `$lib/stores/auth.svelte.ts`. The current user id is also pushed into the data layer's `current-user.ts` so the Dexie creating-hook can auto-stamp `userId` on every record. Module stores never need to know who the current user is. ### Route Protection -**Web**: Automatic via `authGuard` hook in `hooks.server.ts`. Routes in `(app)` group are protected. +The `(app)` group is wrapped by an `AuthGate` component that redirects unauthenticated users to `/login` and reads the access tier from the JWT to gate beta/alpha/founder-only modules. -**Mobile**: Handled by `AuthProvider` in `_layout.tsx` which redirects unauthenticated users to `/login`. +## Reference Documents -## Multi-App Branding - -When adding new apps to the ecosystem, update `apps/web/src/lib/config/apps.ts` with: - -- App name and display name -- Tagline and description -- Logo emoji and colors -- Feature list for marketing -- Dashboard route - -The welcome page will automatically render the appropriate branding based on app context. +| Path | Purpose | +|------|---------| +| `apps/web/src/lib/data/DATA_LAYER_AUDIT.md` | Complete data-layer architecture, sync engine, encryption rollout, threat model, backlog | +| `apps/docs/src/content/docs/architecture/security.mdx` | User-facing security & encryption walkthrough | +| `apps/docs/src/content/docs/architecture/authentication.mdx` | Auth flow + JWT structure | +| Root `CLAUDE.md` | Monorepo overview, dev commands, sibling services | diff --git a/services/mana-auth/CLAUDE.md b/services/mana-auth/CLAUDE.md index 3912d693c..924474f8c 100644 --- a/services/mana-auth/CLAUDE.md +++ b/services/mana-auth/CLAUDE.md @@ -49,6 +49,26 @@ OpenID Connect provider for Matrix/Synapse SSO. Aggregates data from 3 sources: auth DB (sessions, accounts, 2FA, passkeys), mana-credits (balance, transactions), mana-sync DB (entity counts per app). +### Encryption Vault (`/api/v1/me/encryption-vault/*`) + +Per-user master-key custody for the Mana data-layer encryption. The browser fetches its master key here on first login and re-fetches on each session start. The key itself never lives in the database — it's wrapped with the service-wide KEK (loaded from `MANA_AUTH_KEK`). + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/status` | Cheap metadata read: `{ vaultExists, hasRecoveryWrap, zeroKnowledge, recoverySetAt }`. No decryption, no audit row. Used by the settings page on mount. | +| POST | `/init` | Idempotent vault initialisation. Mints + KEK-wraps a fresh master key on first call, returns the existing one on subsequent calls. | +| GET | `/key` | Hot path. Returns either `{ masterKey, formatVersion, kekId }` (standard mode) or `{ requiresRecoveryCode: true, recoveryWrappedMk, recoveryIv }` (zero-knowledge mode). | +| POST | `/rotate` | Mints a fresh master key. Old MK is gone — caller must re-encrypt or accept loss. **Forbidden in zero-knowledge mode** (`409 ZK_ROTATE_FORBIDDEN`). | +| POST | `/recovery-wrap` | Stores a client-built recovery wrap: `{ recoveryWrappedMk, recoveryIv }`. The recovery secret itself NEVER touches the wire. Idempotent — replaces existing wrap. | +| DELETE | `/recovery-wrap` | Removes the recovery wrap. **Forbidden in zero-knowledge mode** (`409 ZK_ACTIVE`) — would lock the user out. | +| POST | `/zero-knowledge` | Toggles ZK mode. `{ enable: true }` requires a recovery wrap to be set first (else `400 RECOVERY_WRAP_MISSING`). `{ enable: false, masterKey: base64 }` requires the freshly-unwrapped MK from the client so the server can KEK-re-wrap it. | + +All routes write to `auth.encryption_vault_audit` for security investigations. Three database CHECK constraints enforce vault consistency at the schema level (`encryption_vaults_has_wrap`, `encryption_vaults_wrap_iv_pair`, `encryption_vaults_zk_consistency`) so a code-level bug can't accidentally lock a user out. + +Schema lives in `src/db/schema/encryption-vaults.ts`, service in `src/services/encryption-vault/`. Migration files: `sql/002_encryption_vaults.sql` (Phase 2: tables + RLS) and `sql/003_recovery_wrap.sql` (Phase 9: recovery columns + ZK constraints). + +For the full architectural deep-dive, threat model, and rollout history (Phases 1–9 + backlog sweep), see `apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md`. User-facing docs at `apps/docs/src/content/docs/architecture/security.mdx`. + ### Admin (`/api/v1/admin/*`) | Method | Path | Description | |--------|------|-------------| @@ -84,6 +104,13 @@ SMTP_PORT=587 SMTP_USER=... SMTP_PASS=... SYNAPSE_OIDC_CLIENT_SECRET=... + +# Encryption Vault — REQUIRED IN PRODUCTION +# Base64-encoded 32-byte AES-256 key. Generate with `openssl rand -base64 32`. +# The dev fallback is 32 zero bytes (prints a loud warning at startup). +# This key wraps every user's master key in auth.encryption_vaults — guard +# it like a database password. Provision via Docker secret / KMS / Vault. +MANA_AUTH_KEK= ``` ## Critical Rules