managarten/apps/docs/src/content/docs/architecture/security.mdx
Till JS bb3da78d5c feat(ai): Mission Grant rollout gating — flag, alerts, runbook, user docs
Phase 4 — everything needed to flip the Mission Key-Grant feature on
safely per deployment. No new behaviour; purely operational plumbing.

- PUBLIC_AI_MISSION_GRANTS feature flag (default off). hooks.server.ts
  injects window.__PUBLIC_AI_MISSION_GRANTS__, api/config.ts exposes
  isMissionGrantsEnabled(). Grant UI (dialog + status box) and the
  Workbench "Datenzugriff" tab both hide when the flag is off.
- PUBLIC_MANA_AI_URL added to the injection set so the webapp can reach
  the new audit endpoint from production.
- Prometheus alerts (new mana_ai_alerts group):
  - ManaAIServiceDown (warning, 2m)
  - ManaAIGrantScopeViolation (critical, 0m) — MUST stay at 0; any
    increment pages immediately
  - ManaAIGrantSkipsHigh (warning, 15m) — flags keypair drift
  - ManaAIPlannerParseFailures (warning, 10m) — prompt/LLM drift
- Runbook in docs/plans/ai-mission-key-grant.md: initial keypair gen,
  leak-response procedure (rotate + invalidate all grants + audit),
  scope-violation triage.
- User-facing doc in apps/docs security.mdx: new "AI Mission Grants"
  section with the three hard constraints (ZK users blocked, scope
  changes invalidate cryptographically, revocation is one click) plus
  an honest threat-model comparison column showing where grants shift
  the tradeoff.

Rollout remaining (not code): generate keypair on Mac Mini, provision
MANA_AI_PRIVATE_KEY_PEM + MANA_AI_PUBLIC_KEY_PEM via Docker secrets,
flip PUBLIC_AI_MISSION_GRANTS=true starting with till-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:02:47 +02:00

256 lines
13 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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) |
| Period | `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**.
<Steps>
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**.
</Steps>
### 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
<Aside type="caution" title="MANA_AUTH_KEK is required in production">
mana-auth refuses to start without `MANA_AUTH_KEK` set to a base64-encoded 32-byte value. The dev fallback is 32 zero bytes, which prints a loud warning. Generate a real key with:
```bash
openssl rand -base64 32
```
Store it as a Docker secret, KMS-injected env var, or Vault-served value. The KEK never touches the database — it lives only in process memory.
</Aside>
### 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) |
## AI Mission Grants (opt-in, per mission)
By default, AI missions that depend on encrypted data (notes, tasks,
calendar events, journal entries, your Kontext document) run **only
when your browser tab is open** — the background runner on our server
sees ciphertext and physically cannot read them.
Some missions are more useful when they run continuously, even while
you're offline. For those, you can opt in — per mission, not globally
— to a **Mission Key-Grant**. Here is exactly what that does:
1. Your browser derives a fresh key that is bound to:
- The mission's ID.
- The specific table names referenced.
- The specific record IDs referenced.
2. The derived key is wrapped with the mana-ai service's public key
and attached to the mission record.
3. When the mana-ai runner ticks for that mission, it unwraps the
key in memory, decrypts **only the allowlisted records**, plans
the next iteration, and forgets the key at the end of the tick.
4. Every decrypt is logged. You see the full log under **Workbench
→ Datenzugriff**.
Hard constraints — enforced by the code, not by policy:
- **Zero-knowledge users cannot issue grants.** The mana-auth server
has no usable master key in ZK mode; the endpoint refuses.
- **Scope changes invalidate the key cryptographically.** Add a new
record to a mission → the derived key is different → the existing
grant stops working → you're prompted to re-consent. It is not
possible for the runner to "silently expand" its scope.
- **Grants expire.** Default lifetime is 7 days, renewed on every
successful run. Missions that go idle lose their grant automatically;
you re-consent on the next edit.
- **Revocation is one click.** The lock icon in the Workbench removes
the grant; the mission keeps its history but stops running
server-side until you re-grant.
- **The runner never writes under a grant** — it only reads. All
changes still go through the normal proposal-approve flow you
control.
| Threat | Standard | With a Mission Grant | Zero-Knowledge |
|--------|----------|----------------------|----------------|
| Mana operator reads an unrelated record of the same user | ⚠️ Could decrypt with KEK | ✅ Cannot — key is scoped | ✅ Cannot |
| Mana operator reads the granted records of the grant-enabled mission | ⚠️ Could decrypt with KEK | ⚠️ Could decrypt with the grant key + record ciphertext | ✅ Cannot |
| Court order against Mana for the granted-mission records | ⚠️ Could be compelled | ⚠️ Could be compelled (while grant is active) | ✅ Mana physically cannot comply |
| Runner RAM-dump during the 60s tick | ⚠️ n/a | ⚠️ Could expose the grant key for one tick window | ✅ n/a |
The tradeoff is deliberate: you exchange a small, scoped privacy
reduction for autonomy on one mission. Missions without a grant keep
the full standard / ZK guarantees.
## Implementation references
For the architectural deep dive, code locations, and the complete rollout history (Phases 19 + 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 |