From 6882ffb62620459ecf7c0b4c5c500c8981b646a1 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 15 Apr 2026 13:41:35 +0200 Subject: [PATCH] feat(shared-ai): Mission Key-Grant contract + plan for encrypted server-side runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for Phase 2+ of the Mission Key-Grant flow: lets mana-ai execute missions that depend on encrypted inputs (notes/tasks/events/ journal/kontext) without needing an open browser tab. Opt-in per mission, Zero-Knowledge users excluded. - Canonical HKDF-SHA256 derivation (scope-bound via tables + recordIds in the HKDF info string → scope changes invalidate the grant cryptographically, not just via a runtime check) - Mission.grant field on the shared Mission type - Golden snapshot + drift-guard test so webapp wrap path and mana-auth wrap endpoint can't silently diverge - Ideas backlog at docs/future/AI_AGENTS_IDEAS.md - Full rollout plan at docs/plans/ai-mission-key-grant.md - COMPANION_BRAIN_ARCHITECTURE.md §21 captures the flow + privacy guarantees + non-goals Co-Authored-By: Claude Opus 4.6 (1M context) --- .../COMPANION_BRAIN_ARCHITECTURE.md | 42 +++++ docs/future/AI_AGENTS_IDEAS.md | 129 ++++++++++++++ docs/plans/ai-mission-key-grant.md | 139 +++++++++++++++ packages/shared-ai/src/index.ts | 9 + packages/shared-ai/src/missions/grant.test.ts | 115 +++++++++++++ packages/shared-ai/src/missions/grant.ts | 160 ++++++++++++++++++ packages/shared-ai/src/missions/index.ts | 8 + packages/shared-ai/src/missions/types.ts | 8 + 8 files changed, 610 insertions(+) create mode 100644 docs/future/AI_AGENTS_IDEAS.md create mode 100644 docs/plans/ai-mission-key-grant.md create mode 100644 packages/shared-ai/src/missions/grant.test.ts create mode 100644 packages/shared-ai/src/missions/grant.ts diff --git a/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md b/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md index f3e24ac23..a6af61481 100644 --- a/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md +++ b/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md @@ -1850,3 +1850,45 @@ await executeTool( ``` Ghost-Karte erscheint sofort ueber der Task-Liste. + +## 21. Mission Key-Grant (ab 2026-04-15, in Arbeit) + +Opt-in Mechanismus der es `mana-ai` erlaubt, Missions auf **encrypted** Tabellen (notes, tasks, events, journal, kontext) serverseitig auszufuehren — ohne dass ein Browser-Tab des Users offen sein muss. Ohne Grant bleibt der Foreground-Runner zustaendig; das ist der Default und aendert sich nicht. + +Vollstaendiger Plan: [`docs/plans/ai-mission-key-grant.md`](../plans/ai-mission-key-grant.md). Ideen-Kontext: [`docs/future/AI_AGENTS_IDEAS.md`](../future/AI_AGENTS_IDEAS.md#1-encrypted-tables-serverseitig-nutzbar-machen). + +### Flow + +1. **Consent** — User aktiviert Mission mit encrypted Input → `MissionGrantDialog` erklaert Record-Scope und TTL, fragt explizit um Erlaubnis. Zero-Knowledge-User sehen den Dialog nicht; Grant ist dort hart deaktiviert. +2. **Derivation** — Webapp ruft `deriveMissionDataKey(masterKey, { version, missionId, tables, recordIds })` aus `@mana/shared-ai`. HKDF-SHA256 mit `missionId` als Salt; Scope-Binding im `info`-String → jede Scope-Aenderung erzeugt kryptografisch einen anderen Key, alte Grants werden automatisch ungueltig. +3. **Wrap** — `mana-auth` `POST /me/ai-mission-grant` wrappt den MDK mit dem RSA-OAEP-2048 Public-Key von `mana-ai` (aus `MANA_AI_PUBLIC_KEY_PEM`). Antwort: `{ wrappedKey, derivation, issuedAt, expiresAt }` → Webapp schreibt das als `Mission.grant`. +4. **Sync** — Grant-Blob fliesst ueber das normale Sync-System an `mana_sync`. Keine Sonderbehandlung — `wrappedKey` ist bereits RSA-geschuetzt. +5. **Unwrap** — `mana-ai` holt beim Tick den privaten Key (`MANA_AI_PRIVATE_KEY_PEM`), entwrappt den MDK nur im Prozessspeicher, liest allowlisted Records aus `sync_changes`, entschluesselt, plant. +6. **Audit** — jede Entschluesselung schreibt eine Zeile in `mana_ai.decrypt_audit` (RLS-scoped auf `app.current_user_id`). User kann das in der Workbench unter "Mission -> Datenzugriff" einsehen. +7. **Lifecycle** — Grant hat Default-TTL 7 Tage rollend. Revoke via Workbench-Button → `grant=null`, Mission pausiert (`state='grant-revoked'`). Abgelaufen → Runner ueberspringt mit `state='grant-missing'`, Foreground-Runner uebernimmt beim naechsten Tab-Open. + +### Komponenten (Status) + +| Komponente | Wo | Status | +|---|---|---| +| Canonical HKDF + Types | `packages/shared-ai/src/missions/grant.ts` | done (Phase 1a) | +| `Mission.grant` Feld | `packages/shared-ai/src/missions/types.ts` | done | +| `mana_ai.decrypt_audit` + RLS | `services/mana-ai/src/db/migrate.ts` | done (Phase 1b) | +| `MANA_AI_PUBLIC_KEY_PEM` / `MANA_AI_PRIVATE_KEY_PEM` config | auth + ai configs | done (Phase 0) | +| `POST /me/ai-mission-grant` Endpoint | `services/mana-auth/src/routes/encryption-vault.ts` | Phase 1c | +| Server-side unwrap helper | `services/mana-ai/src/crypto/unwrap-grant.ts` | Phase 1d | +| Encrypted input resolver | `services/mana-ai/src/db/resolvers/encrypted.ts` | Phase 2 | +| Consent UI + Revoke | `apps/mana/apps/web/src/lib/components/ai/MissionGrantDialog.svelte` | Phase 3 | + +### Privacy-Garantien + +- **Zero-Knowledge-User bleiben Zero-Knowledge.** Die Webapp verweigert das Anlegen eines Grants, wenn `vault.zeroKnowledge=true`. Endpoint pruefts zusaetzlich serverseitig. +- **Kein Key-Cache.** `mana-ai` entwrappt den MDK pro Tick neu und vergisst ihn im `finally`. Minimiert RAM-Dump-Window auf die Tick-Dauer. +- **Scope-Verletzung = Crypto-Failure.** Record-IDs sind in die Key-Derivation gebunden. Runtime-Allowlist-Check ist belt+braces, nicht die alleinige Verteidigung. +- **Keine Write-Grants.** Server staget nur Proposals; User genehmigt wie bisher. Grant = read-only. + +### Nicht-Ziele + +- Cross-User-Missions (pro Grant genau ein User). +- Automatische Key-Rotation (Master-Key-Rotation invalidiert alle Grants → User re-consented beim naechsten Edit). +- Grant-Sync-Konflikte (werden ueber normales LWW aufgeloest; bei Scope-Mismatch wirft der Resolver `scope-violation` und die Mission pausiert). diff --git a/docs/future/AI_AGENTS_IDEAS.md b/docs/future/AI_AGENTS_IDEAS.md new file mode 100644 index 000000000..39eb7a45f --- /dev/null +++ b/docs/future/AI_AGENTS_IDEAS.md @@ -0,0 +1,129 @@ +# AI Agents — Verbesserungsideen + +Backlog für das AI-Workbench / Mission-Runner-System (`services/mana-ai`, `@mana/shared-ai`, `apps/mana/apps/web/src/lib/data/ai/`). Stand: 2026-04-15, nach Abschluss der v0.3-Roadmap (materialisierte Snapshots, Metrics, Revert). + +Kontext: [`services/mana-ai/CLAUDE.md`](../../services/mana-ai/CLAUDE.md), [`docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md`](../architecture/COMPANION_BRAIN_ARCHITECTURE.md). + +Sortiert grob nach Impact. + +--- + +## 1. Encrypted-Tables serverseitig nutzbar machen + +**Problem:** Missions, die notes / kontext / tasks / events / journal als Input brauchen, laufen aktuell **nur im Vordergrund** (offener Browser-Tab), weil diese Tabellen at-rest AES-GCM-verschlüsselt sind und der Server keinen Data-Key hat. Goals etc. (plaintext) laufen serverseitig — asymmetrische UX. + +**Ideen:** +- **Per-Mission Key-Grant:** User autorisiert beim Mission-Start einmalig einen KEK-gewrappten Data-Key, scoped auf exakt die referenzierten Record-IDs und die Mission-TTL. Key lebt nur im RAM von `mana-ai`, wird nie persistiert. +- **Headless-Client statt Serverentschlüsselung:** ein Service-Worker- oder WebWorker-"AI-Proxy" im Browser treibt Missions auch ohne offenes UI. Kein Key verlässt das Device; dafür Abhängigkeit von Push / Wake-Lock. + +Details + Trade-offs siehe unten — "Details zu Punkt 1". + +## 2. Proposal-Qualität messbar machen + +Reject-Feedback bleibt heute liegen. Ideen: +- **Reject-Reasons clustern** (LLM-Klassifikation `tooAggressive` | `wrongInput` | `alreadyDone` | `badTiming` | `other`) → als Signal in den Planner-Prompt der nächsten Iteration injizieren. +- **Accept-Rate pro Tool** als Prometheus-Gauge (`mana_ai_proposal_accept_rate{tool=...}`). Tools unter Schwelle X automatisch deaktivierbar. +- **Prompt-A/B:** gleiche Mission, zwei Prompt-Varianten, Accept-Rate als Metrik. + +## 3. Scheduler-Intelligenz + +`nextRunAt` ist heute ein stumpfes Intervall. Besser: +- **Adaptive Backoff:** nach n Parse-Failures oder konsekutiven 0-Proposal-Iterationen exponentiell verlangsamen; nach Accepts beschleunigen. +- **Input-getriggertes Tick:** Mission wird wach, wenn ein referenzierter Input sich ändert (`sync_changes`-Trigger oder NOTIFY/LISTEN) statt nach Wall-Clock. Spart LLM-Calls drastisch bei seltenen Input-Updates. + +## 4. Mission-Templates / Catalog + +Users müssen heute Missions from-scratch konfigurieren. Ein kuratierter Katalog ("Weekly Review", "Inbox Triage", "Goal Check-in", "Kontext Verdichten") mit vordefinierten Inputs, Prompt-Overlay und Policies. 10× niedrigere Einstiegshürde, konsistentere Ergebnisse. + +## 5. Multi-Step Proposals (Plans statt Steps) + +Aktuell: ein PlanStep = ein Proposal. Für zusammenhängende Aktionen (z. B. "Task erstellen **und** Kalendereintrag dazu") sollte es ein **Plan-Level-Accept** geben — atomar, mit atomarem Revert. Dafür braucht es eine neue `proposalGroupId` auf Dexie-Ebene und eine Group-UI in der Inbox. + +## 6. Cost / Token Budget pro Mission + +LLM-Calls sind nicht gratis. Pro Mission `maxTokensPerDay` + gerollter Verbrauch, sichtbar in der Workbench. Runner stoppt automatisch bei Überschreitung und markiert `state='budget-exceeded'`. Dashboard-Gauge für Gesamtsystem. + +## 7. Workbench-UX + +- **Diff-Ansicht pro Iteration:** was hätte der Accept konkret geändert (Before/After-Panel). Wir haben `__fieldActors`, also können wir den hypothetischen Post-Accept-State projizieren. +- **Batch-Accept / -Reject** via Tastatur (`a` / `r`, mit `shift` für alle in der Iteration). +- **"Warum?"-Button** auf jedem Proposal → zeigt `rationale` + die konkreten Input-Records, die der Planner zitiert hat. Schließt die Vertrauenslücke. + +## 8. Reliability + +- **Multi-Instance-Deploy** mit Postgres-Advisory-Locks auf Snapshot-Refresh und Mission-Claim (steht schon auf der polish-Liste in `services/mana-ai/CLAUDE.md`). +- **Dead-Letter:** nach n konsekutiven Parse-Failures pro Mission → `state='errored'` mit letzter Error-Message, statt still weiter zu ticken. +- **Planner-Prompt-Versionierung:** `iteration.promptVersion` (Hash des Prompt-Templates) → reproduzierbare Reviews + saubere Migrationsbasis, wenn wir Prompts umbauen. + +--- + +## Details zu Punkt 1: Encrypted-Tables serverseitig nutzbar machen + +### Status quo + +Privacy-Modell siehe `services/mana-ai/src/db/resolvers/types.ts`: +- `mana-ai` hat `SYNC_DATABASE_URL` nur lesend + scoped via RLS (`withUser`). +- Für `tables` in der Encryption-Registry (`apps/mana/apps/web/src/lib/data/crypto/registry.ts`) liegen im Postgres nur AES-GCM-Ciphertexts. +- Master-Key lebt in `mana-auth`, KEK-gewrappt via `MANA_AUTH_KEK`. +- **Foreground-Runner** (Browser-Tab) kann entschlüsseln, weil er die gleiche Auth-Session hat. **Background-Runner** (`mana-ai`) kann nicht. + +Konsequenz: die interessantesten Inputs (Notes, freie Texte im Kontext, Journal-Einträge) sind für autonome Missions tot. + +### Option A — Per-Mission Key-Grant (serverseitige Entschlüsselung, scoped) + +**Mechanik:** +1. User erstellt / aktiviert eine Mission mit einer encrypted-Input-Source. UI fragt explizit: _"Diese Mission liest deine Notes/Kontext. Serverseitige Ausführung benötigt Zugriff auf den Entschlüsselungs-Key. OK?"_ +2. Webapp leitet aus dem Master-Key einen **Mission-Data-Key** ab: + `MDK = HKDF(masterKey, info="mission:"+missionId+":"+tableAllowlist)` +3. `MDK` wird via `mana-auth` public-key-verschlüsselt für `mana-ai` (Service-Public-Key) und als `Mission.grant.{wrappedKey, expiresAt, tables, recordIdAllowlist?}` am Mission-Record gespeichert. +4. `mana-ai` entwrappt beim Tick, hält Plaintext-Key nur im RAM, löscht ihn am Tick-Ende. Nie Disk, nie Log. +5. Grant hat TTL (z. B. 7 Tage rollend) und `recordIdAllowlist` — der Server darf nur explizit referenzierte Records entschlüsseln, nicht die ganze Tabelle. + +**Vorteile:** +- Einheitliches Ausführungsmodell (alle Missions laufen serverseitig). +- User kontrolliert Scope: pro Mission, pro Tabellen-Subset, mit Ablauf. +- Key ist *abgeleitet*, nie der Master. Kompromittierte Mission → nur deren Records exponiert. + +**Nachteile / Risiken:** +- Privacy-Promise "encrypted data never leaves client in plaintext" wird aufgeweicht — muss in UX ehrlich kommuniziert werden. +- `mana-ai`-RAM-Dump = Key-Leak. Mitigation: Prozess-Isolation, `mlock`, kurze Grant-TTL, Audit-Log jeder Entschlüsselung. +- Zero-Knowledge-Mode (Settings → Sicherheit) ist inkompatibel → dort muss A hart disabled sein, User fällt auf Option B zurück. +- Key-Rotation wird komplex: rotiert der Master-Key, müssen alle aktiven Grants re-wrapped werden. + +**Aufwand:** mittel-hoch. Neue Felder in `aiMissions`, Key-Derivation in `mana-auth`, Decrypt-Pfad in `mana-ai`, Resolver-Erweiterung, UX-Dialog. ~1–2 Sprints. + +### Option B — Headless Client (browserseitige Ausführung ohne offenen Tab) + +**Mechanik:** +1. Service-Worker + `Periodic Background Sync` / Push auf dem User-Device treibt einen dedizierten "AI-Runner-Worker", der dieselbe IndexedDB + denselben Decrypt-Pfad nutzt wie der Foreground-Runner. +2. `mana-ai` wird zum **Trigger** statt zum Executor: findet due Missions, sendet via Web-Push "wake up user X's device". Device führt Planner aus, schreibt Iterationen via sync zurück. +3. Fallback: wenn kein Device seit N Minuten geantwortet hat, Mission bleibt pending (nicht ausgeführt — aber keine Privacy-Verletzung). + +**Vorteile:** +- **Keine Schlüssel verlassen das Device.** Privacy-Promise bleibt intakt, Zero-Knowledge-Mode weiterhin unterstützt. +- Wiederverwendung des gesamten Foreground-Pfads (Decrypt, Staging, Proposal-Erzeugung) — kein Drift-Risiko zwischen zwei Implementierungen. + +**Nachteile / Risiken:** +- Browser-Hintergrund-APIs sind **unzuverlässig**. `Periodic Background Sync` ist Chrome-only + opportunistisch; Safari hat nichts Vergleichbares. Web-Push zwingt zu Notification-Permission. +- Latenz unvorhersehbar: Mission kann stundenlang schlafen, wenn kein Device online ist. +- Multi-Device: welcher Worker führt aus? Braucht Device-Leader-Election (z. B. über sync-gestütztes Heartbeat-Feld). +- Dev-Komplexität: Service-Worker-Updates sind zickig; IndexedDB-Lifecycle im Worker vs. Tab. + +**Aufwand:** hoch. Neue Service-Worker-Entry, Push-Infra in `mana-notify`, Device-Leader-Logik, ausführliche Fallback-Tests über Browser. ~2–3 Sprints. + +### Option C — Hybrid (default) + +Foreground-Runner bleibt primär für encrypted Missions (= Option B ohne Push, nur wenn Tab offen). Option A **opt-in pro Mission** für User, die Server-Autonomie explizit wollen. Goals / plaintext-Missions unverändert serverseitig. + +- Default: kein neues Risiko, kein Regression. +- Power-User bekommen echte autonome Missions auf sensiblen Daten mit explizitem Consent. +- Zero-Knowledge-User blockiert Option A komplett, keine Sonderfälle. + +**Empfehlung:** **C als Zielbild, Option A als Unterbau bauen.** Option B ist attraktiv, aber die Browser-Hintergrund-APIs sind zu unzuverlässig, um Verlass darauf zu schaffen — taugt höchstens als Augmentierung ("run more often when a device is online"), nicht als Ersatz. + +### Offene Fragen + +- Key-Scope: pro Mission ein Key (einfache Revocation) oder pro Tabelle (weniger Wrap-Operationen)? → vermutlich pro Mission. +- Audit: wo loggen wir serverseitige Decrypts? Eigene Tabelle `mana_ai.decrypt_audit` mit `{missionId, recordId, tickId, timestamp}`, vom User in der Workbench einsehbar. +- Revocation-UX: ein "🔒 Key zurückziehen"-Button pro Mission in der Workbench → Grant wird gelöscht, Mission pausiert, nächster Tick bemerkt den fehlenden Grant. +- Prompt-Injection: entschlüsselter User-Content geht in den Planner-Prompt. Braucht stärkere Prompt-Isolation (klare Marker, Output-Validation) — aber das gilt heute schon für Goals. diff --git a/docs/plans/ai-mission-key-grant.md b/docs/plans/ai-mission-key-grant.md new file mode 100644 index 000000000..98001bf17 --- /dev/null +++ b/docs/plans/ai-mission-key-grant.md @@ -0,0 +1,139 @@ +# Plan: Mission Key-Grant — Serverseitige Mission-Ausführung auf verschlüsselten Daten + +**Status:** Draft, 2026-04-15 +**Scope:** Ermöglicht `mana-ai`, Missions auf encrypted Tabellen (notes, tasks, events, journal, kontext) autonom auszuführen — opt-in pro Mission, Zero-Knowledge-User ausgeschlossen. +**Ziel-Architektur:** Option C (Hybrid) aus [`docs/future/AI_AGENTS_IDEAS.md`](../future/AI_AGENTS_IDEAS.md#1-encrypted-tables-serverseitig-nutzbar-machen). Foreground-Runner bleibt default; Key-Grant ist der Opt-in-Pfad. +**Vorarbeit:** v0.3 Mission Runner ([`services/mana-ai/CLAUDE.md`](../../services/mana-ai/CLAUDE.md)) abgeschlossen. + +--- + +## Entscheidungen (baked in) + +Diese sind jetzt fixiert, damit die Implementierung nicht bei jedem Schritt blockiert: + +| Frage | Entscheidung | Begründung | +|---|---|---| +| **Key-Scope** | Pro Mission ein abgeleiteter Key (`MDK`) | Revocation simpel = Grant löschen; ein kompromittierter MDK leakt nur eine Mission | +| **Derivation** | `MDK = HKDF-SHA256(masterKey, salt=missionId, info="mana-ai-mission-grant:v1:"+tablesSorted+":"+recordIdsSorted)` | Bindet MDK kryptografisch an Mission + Scope. Scope-Änderung = neuer Key. Version im `info`-String erlaubt Rotation der Derivation-Policy ohne Master-Key-Rotation. | +| **Wrapping** | RSA-OAEP-2048 Public-Key von `mana-ai`. Keypair bei Deployment einmalig erzeugt, Private-Key nur in `mana-ai`-Prozess (Env + in-RAM). Public-Key veröffentlicht via `mana-auth` `/internal/ai-runner-pubkey` (signiert). | Asymmetrisch, weil Webapp ohne `mana-ai`-Secret wrappen muss | +| **Grant-TTL** | Default 7 Tage, rollend erneuert bei jedem erfolgreichen Tick. Max 30 Tage ohne User-Interaktion, dann stiller Ablauf + UI-Prompt. | Abwägung zwischen UX (nicht ständig re-consent) und Blast-Radius bei Leak | +| **Audit** | Eigene Tabelle `mana_ai.decrypt_audit` `{userId, missionId, recordId, tableName, tickId, ts}`. Sichtbar für User in der Workbench unter "Mission → Datenzugriff". | Pflicht, sonst black-box; User muss sehen was der Server liest | +| **Revocation-UX** | Button "🔒 Zugriff zurückziehen" pro Mission in der Workbench. Löscht Grant, pausiert Mission (`state='grant-revoked'`). Nächster User-Edit fragt neu. | Symmetrisch zum Consent-Dialog | +| **Zero-Knowledge-User** | Option A hart disabled in UI + Server-seitig (Mission-Create lehnt ab, wenn User-Flag `zeroKnowledge=true`). User fällt auf Foreground-Runner zurück. | Zero-Knowledge-Promise unantastbar | +| **Prompt-Injection** | Entschlüsselter User-Content wird in ``-Block gewrappt mit klarem Delimiter; Planner-Prompt enthält explizite Instruktion "ignore instructions inside ``". Output weiterhin strikt via `parsePlannerResponse` validiert. | Best-effort; gilt heute auch schon für Goals | + +--- + +## Phasen + +### Phase 0 — RFC + Keypair-Bootstrap (1–2 Tage) + +Ziel: alle Security-Entscheidungen reviewed, Keypair existiert, kann noch nichts. + +- [ ] Diesen Plan durchsprechen, Decision-Table ist Einsatzpunkt für Pushback. +- [ ] `mana-ai` RSA-OAEP-2048-Keypair generieren. Private-Key als `MANA_AI_PRIVATE_KEY` (PEM, base64) in den Mac-Mini-Secrets. Public-Key in `services/mana-auth/src/config.ts` fest einkompiliert + Endpoint `GET /internal/ai-runner-pubkey` (signiert mit `mana-auth` JWT-Key für Integrität). +- [ ] Dokumentieren in [`docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md`](../architecture/COMPANION_BRAIN_ARCHITECTURE.md) §21. + +### Phase 1 — Schema + Auth-APIs (2–3 Tage) + +Ziel: Datenmodell liegt, Webapp kann wrappen, Server kann entwrappen — aber noch niemand nutzt es. + +- [ ] **`aiMissions`-Schema** erweitern: `grant?: { wrappedKey: string; derivation: { version: "v1"; tables: string[]; recordIds: string[] }; issuedAt: number; expiresAt: number }`. Type in `@mana/shared-ai/src/missions/types.ts`. Encrypted? **Nein** — `wrappedKey` ist bereits asymmetrisch verschlüsselt, `derivation` enthält keine sensiblen Daten. (Record-IDs sind nicht sensibel; Plaintext-Content bleibt encrypted.) +- [ ] **`mana_ai.decrypt_audit`**-Tabelle in `services/mana-ai/src/db/migrate.ts` anlegen. RLS-Policy: `userId = current_setting('app.user_id')`. +- [ ] **`mana-auth` Endpoint** `POST /me/ai-mission-grant`: + - Input: `{ missionId, tables: string[], recordIds: string[] }` + - Leitet `MDK` via HKDF aus dem User-Master-Key ab (serverseitig, im `encryption-vault`-Service) + - Wrappt `MDK` mit `mana-ai` Public-Key + - Returned `{ wrappedKey, derivation, issuedAt, expiresAt }` + - Code: `services/mana-auth/src/routes/encryption-vault.ts` + `services/mana-auth/src/services/encryption-vault/mission-grant.ts` (neu). Tests mit Fixed-Keys. +- [ ] **`mana-ai` Unwrap-Helper** (`services/mana-ai/src/crypto/unwrap-grant.ts`): + - `unwrapGrant(grant): Promise<{ mdk: CryptoKey; expiresAt: number }>` + - Nutzt `crypto.subtle.unwrapKey` mit privatem RSA-Key + - **Keine Persistenz** — Caller muss `mdk` nach Use vergessen (`key = null`) + - Unit-Tests mit Round-Trip (webapp wrap → server unwrap). +- [ ] **Drift-Guard-Test**: shared `@mana/shared-ai/src/missions/grant-contract.ts` mit kanonischer HKDF-Derivation; Webapp + `mana-auth` importieren daraus; Test vergleicht Ergebnis beider Pfade. + +### Phase 2 — Server-Decrypt + Resolver-Erweiterung (3–4 Tage) + +Ziel: `mana-ai` kann eine Mission mit Grant abarbeiten, liest encrypted Inputs serverseitig, wirft Key nach Tick weg. + +- [ ] **Per-Tick Key-Scope**: `runTickOnce` bekommt eine `KeyScope`-Map `missionId → MDK`. Gefüllt aus `grant`-Feld der Mission am Tick-Start, geleert im `finally`. +- [ ] **Encrypted Resolver** (`services/mana-ai/src/db/resolvers/encrypted.ts`): + - Gegenstück zu den Plaintext-Resolvern, nimmt `mdk` + `recordIdAllowlist` + - Liest Ciphertext-Rows aus `mana_sync`, entschlüsselt via `crypto.subtle.decrypt('AES-GCM')`, gibt Plaintext zurück + - Schreibt **jede Entschlüsselung** in `mana_ai.decrypt_audit` + - Enforced dass `recordId` in `grant.derivation.recordIds` enthalten ist — sonst Abort + Metrik `mana_ai_grant_scope_violations_total` +- [ ] **Registry** in `services/mana-ai/src/db/resolvers/index.ts`: registriert encrypted Resolver für `notes`, `tasks`, `events`, `journal`, `kontext`. +- [ ] **Fallback**: Mission hat Input aus encrypted Tabelle, aber kein `grant` oder Grant abgelaufen → Runner überspringt Mission mit `state='grant-missing'` (statt Error); Foreground-Runner übernimmt bei nächstem Tab-Open. +- [ ] **Metriken**: `mana_ai_decrypts_total{table=}`, `mana_ai_grant_expirations_total`, `mana_ai_grant_scope_violations_total`. +- [ ] Tests in `services/mana-ai/src/db/resolvers/encrypted.test.ts`: round-trip encrypt→decrypt, Scope-Violation, Audit-Row geschrieben. + +### Phase 3 — Webapp: Consent-Flow + Grant-Lifecycle (2–3 Tage) + +Ziel: User kann Grant geben/zurückziehen, UX ist ehrlich. + +- [ ] **Consent-Dialog**: neue Komponente `MissionGrantDialog.svelte`. Triggert beim Mission-Create/-Edit wenn: + - Mindestens ein Input aus encrypted Tabelle + - Mission-`cadence` impliziert autonomer Betrieb (`serverRun=true`) + - User nicht Zero-Knowledge +- [ ] **Dialog-Content**: explizit, ohne Dark Pattern: + - "Diese Mission liest: 3 Notes, 2 Tasks. Um ohne offenen Browser laufen zu können, wird dem AI-Runner ein Zugriffsschlüssel erteilt — gebunden an diese Mission, diese Records, 7 Tage. Jeder Zugriff wird geloggt. [Zurückziehen] kannst du jederzeit. [Verstanden & erteilen] [Nur bei offenem Tab ausführen]" +- [ ] **Bei Consent**: Webapp ruft `mana-auth` `POST /me/ai-mission-grant`, schreibt `grant`-Feld in die Mission. +- [ ] **Revoke-UI**: in `/companion/workbench` pro Mission ein Lock-Icon; Klick → DELETE-Request, `grant=null`, `state='paused'`. +- [ ] **Scope-Change**: User fügt neuen Input hinzu → Grant automatisch invalidiert (Record-IDs Teil der Derivation), UI zeigt "Zugriff erneuern" Prompt. +- [ ] **Audit-Sicht**: "Mission → Datenzugriff"-Tab rendert `decrypt_audit`-Rows via neuem `GET /internal/audit?missionId=` in `mana-ai` (read-only, user-scoped). + +### Phase 4 — Rollout (1–2 Tage) + +- [ ] **Feature-Flag**: `PUBLIC_AI_MISSION_GRANTS=false` default. Dogfood zuerst (till only), dann beta-tier, dann alpha. +- [ ] **Status-Page**: blackbox-probe auf `mana-ai` `/health` existiert schon; zusätzlich Alerting auf `mana_ai_grant_scope_violations_total > 0` (darf nie vorkommen). +- [ ] **Runbook**: Was tun wenn `MANA_AI_PRIVATE_KEY` leaked? → Keypair rotieren, alle Grants invalidieren (simples `UPDATE aiMissions SET grant=null`), User bekommen Re-Consent-Prompts. +- [ ] **Docs-Update**: [`apps/docs/src/content/docs/architecture/security.mdx`](../../apps/docs/src/content/docs/architecture/security.mdx) — neuer Abschnitt "AI Mission Grants". + +--- + +## Files (neu / modifiziert) + +**Neu:** +- `packages/shared-ai/src/missions/grant-contract.ts` — kanonische HKDF-Derivation +- `services/mana-auth/src/services/encryption-vault/mission-grant.ts` +- `services/mana-ai/src/crypto/unwrap-grant.ts` +- `services/mana-ai/src/db/resolvers/encrypted.ts` +- `apps/mana/apps/web/src/lib/components/ai/MissionGrantDialog.svelte` +- `docs/plans/ai-mission-key-grant.md` (dieses Dokument) + +**Modifiziert:** +- `packages/shared-ai/src/missions/types.ts` — `grant`-Feld +- `services/mana-auth/src/routes/encryption-vault.ts` — neuer Endpoint +- `services/mana-auth/src/config.ts` — `mana-ai` Public-Key +- `services/mana-ai/src/db/migrate.ts` — `decrypt_audit`-Tabelle +- `services/mana-ai/src/cron/tick.ts` — KeyScope-Lifecycle +- `services/mana-ai/src/db/resolvers/index.ts` — encrypted Resolver registrieren +- `services/mana-ai/src/metrics.ts` — neue Counter +- `apps/mana/apps/web/src/lib/data/ai/missions/setup.ts` — Consent-Flow-Trigger +- `apps/mana/apps/web/src/routes/(app)/companion/workbench/+page.svelte` — Revoke-Button, Audit-Tab +- `docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md` — §21 "Mission Grants" +- `apps/docs/src/content/docs/architecture/security.mdx` — user-facing + +--- + +## Risiken & Gegenmaßnahmen + +| Risiko | Mitigation | +|---|---| +| **`MANA_AI_PRIVATE_KEY` leakt** → Angreifer kann alle aktiven Grants entwrappen | Kurze Grant-TTL; Rotationsprozedur dokumentiert; Key nur via Mac-Mini-Secrets, nicht im Repo | +| **Scope-Violation** (Server liest Record außerhalb Allowlist) | Runtime-Check + Metrik mit Alert > 0; Unit-Test mit gezielter Allowlist | +| **Prompt-Injection** aus User-Content | ``-Delimiter, explizite Prompt-Instruktion, strikter Output-Parser (gibt's schon) | +| **User versteht Consent-Dialog nicht** → "Gewohnheits-OK" | Explizit Record-Count + Record-Titel zeigen, nicht nur Tabellennamen; Zurückziehen 1-Klick | +| **Grant-Sync-Race** (2 Devices schreiben gleichzeitig verschiedene Grants) | Grant ist Teil der Mission, LWW pro Feld → letzter gewinnt; Server nutzt *seinen* aktuellen Blick — falls Scope Mismatch → Scope-Violation wird geworfen, safe fail | +| **Master-Key-Rotation invalidiert alle MDKs** | Bewusst akzeptiert: nach Rotation müssen Grants neu erteilt werden (UX-Prompt nach Login) | +| **Resolver vergisst Audit-Write bei Exception** | Audit-Write im `try` **vor** dem Decrypt; wenn Decrypt failed, Audit-Row hat `{status: 'failed', reason}` | + +--- + +## Nicht-Ziele + +- **Zero-Knowledge-User bekommen das nicht.** Die bleiben beim Foreground-Runner. Wenn sie Autonomie wollen, müssen sie ZK abschalten — das ist die Entscheidung die ZK bedeutet. +- **Keine Cross-User-Missions.** Ein Grant ist an einen User gebunden; Multi-User-Kontext ist ein separates Thema. +- **Keine Write-Berechtigung im Grant.** Server staged weiter nur Proposals; User approved wie bisher. Grant = Read-only-Key. +- **Kein Key-Caching über Ticks hinweg.** MDK wird pro Tick neu entwrappt und nach Tick-Ende verworfen. Minimiert RAM-Dump-Window. diff --git a/packages/shared-ai/src/index.ts b/packages/shared-ai/src/index.ts index b8aaae795..cd0032fc0 100644 --- a/packages/shared-ai/src/index.ts +++ b/packages/shared-ai/src/index.ts @@ -17,6 +17,15 @@ export type { MissionIteration, MissionState, PlanStep, + GrantDerivation, + GrantDerivationVersion, + MissionGrant, +} from './missions'; +export { + GRANT_DERIVATION_VERSION, + canonicalInfoString, + deriveMissionDataKey, + deriveMissionDataKeyRaw, } from './missions'; export type { diff --git a/packages/shared-ai/src/missions/grant.test.ts b/packages/shared-ai/src/missions/grant.test.ts new file mode 100644 index 000000000..5dc2f93ff --- /dev/null +++ b/packages/shared-ai/src/missions/grant.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { + GRANT_DERIVATION_VERSION, + canonicalInfoString, + deriveMissionDataKeyRaw, + type GrantDerivation, +} from './grant'; + +const fixedMasterKey = new Uint8Array(32).map((_, i) => i + 1); // 01..20 + +function derivation(overrides: Partial = {}): GrantDerivation { + return { + version: GRANT_DERIVATION_VERSION, + missionId: 'mission-abc', + tables: ['notes', 'tasks'], + recordIds: ['notes:n1', 'tasks:t1'], + ...overrides, + }; +} + +describe('canonicalInfoString', () => { + it('is order-independent in tables and recordIds', () => { + const a = canonicalInfoString(derivation({ tables: ['notes', 'tasks'] })); + const b = canonicalInfoString(derivation({ tables: ['tasks', 'notes'] })); + expect(a).toBe(b); + + const c = canonicalInfoString(derivation({ recordIds: ['notes:n1', 'tasks:t1'] })); + const d = canonicalInfoString(derivation({ recordIds: ['tasks:t1', 'notes:n1'] })); + expect(c).toBe(d); + }); + + it('pins the exact string format', () => { + expect(canonicalInfoString(derivation())).toBe( + 'mana-ai-mission-grant:v1:tables=notes,tasks:ids=notes:n1,tasks:t1' + ); + }); +}); + +describe('deriveMissionDataKeyRaw', () => { + it('returns 32 bytes', async () => { + const mdk = await deriveMissionDataKeyRaw(fixedMasterKey, derivation()); + expect(mdk.length).toBe(32); + }); + + it('is deterministic for the same inputs', async () => { + const a = await deriveMissionDataKeyRaw(fixedMasterKey, derivation()); + const b = await deriveMissionDataKeyRaw(fixedMasterKey, derivation()); + expect(Array.from(a)).toEqual(Array.from(b)); + }); + + it('differs when the mission id changes', async () => { + const a = await deriveMissionDataKeyRaw(fixedMasterKey, derivation({ missionId: 'm1' })); + const b = await deriveMissionDataKeyRaw(fixedMasterKey, derivation({ missionId: 'm2' })); + expect(Array.from(a)).not.toEqual(Array.from(b)); + }); + + it('differs when the scope changes', async () => { + const a = await deriveMissionDataKeyRaw(fixedMasterKey, derivation({ tables: ['notes'] })); + const b = await deriveMissionDataKeyRaw( + fixedMasterKey, + derivation({ tables: ['notes', 'tasks'] }) + ); + expect(Array.from(a)).not.toEqual(Array.from(b)); + + const c = await deriveMissionDataKeyRaw( + fixedMasterKey, + derivation({ recordIds: ['notes:n1'] }) + ); + const d = await deriveMissionDataKeyRaw( + fixedMasterKey, + derivation({ recordIds: ['notes:n1', 'notes:n2'] }) + ); + expect(Array.from(c)).not.toEqual(Array.from(d)); + }); + + it('is order-independent in scope', async () => { + const a = await deriveMissionDataKeyRaw( + fixedMasterKey, + derivation({ tables: ['notes', 'tasks'] }) + ); + const b = await deriveMissionDataKeyRaw( + fixedMasterKey, + derivation({ tables: ['tasks', 'notes'] }) + ); + expect(Array.from(a)).toEqual(Array.from(b)); + }); + + it('rejects unsupported derivation versions', async () => { + await expect( + deriveMissionDataKeyRaw(fixedMasterKey, derivation({ version: 'v0' as never })) + ).rejects.toThrow(/unsupported derivation version/); + }); + + it('rejects wrong-length master keys', async () => { + await expect(deriveMissionDataKeyRaw(new Uint8Array(16), derivation())).rejects.toThrow( + /expected 32-byte master key/ + ); + }); + + it('is stable across runs (golden)', async () => { + // If this test ever needs updating, the derivation policy has + // changed — bump GRANT_DERIVATION_VERSION and keep the old + // branch available for in-flight grants. + const mdk = await deriveMissionDataKeyRaw(fixedMasterKey, derivation()); + const hex = Array.from(mdk) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + // Golden — recorded from the Web Crypto reference implementation + // on first green run. If this string changes, the key-derivation + // policy has changed in a non-backwards-compatible way. + expect(hex).toMatchInlineSnapshot( + `"7538df66c51d3ddb667c0135feb4ac7c2800ba372babc7e61e9423d6a9a65628"` + ); + }); +}); diff --git a/packages/shared-ai/src/missions/grant.ts b/packages/shared-ai/src/missions/grant.ts new file mode 100644 index 000000000..2e1fab231 --- /dev/null +++ b/packages/shared-ai/src/missions/grant.ts @@ -0,0 +1,160 @@ +/** + * Mission Key-Grant — canonical derivation + wire format. + * + * A Grant lets the server-side `mana-ai` runner execute Missions that + * depend on encrypted inputs (notes, tasks, events, journal, kontext). + * The webapp derives a Mission Data Key (MDK) from the user master key, + * wraps it with the `mana-ai` RSA public key, and attaches the blob to + * the Mission. At tick time `mana-ai` unwraps, decrypts only the + * allowlisted records, and forgets the key after the tick. + * + * This file is the SOURCE OF TRUTH for how the MDK is derived. The + * webapp (wrap path) and mana-auth (server-side wrap for the grant + * endpoint) both import `deriveMissionDataKey` from here; a drift-guard + * test keeps them honest. Bumping DERIVATION_VERSION is the supported + * path for changing the derivation policy without rotating the user + * master key. + * + * Why tables + recordIds in the HKDF info? + * Binding the scope into the key means scope escalation is a *crypto* + * failure, not a policy check the server could forget. Adding a new + * record to a Mission produces a different MDK → existing grant + * stops working → UI prompts for re-consent. This is stricter than + * a runtime allowlist check; we keep the runtime check too as belt + * + braces. + */ + +/** Bump this when the derivation policy changes (e.g. new info fields, + * new hash). Existing grants with an older version remain decryptable + * as long as the code path for that version is kept; once dropped, + * users re-consent on next edit. */ +export const GRANT_DERIVATION_VERSION = 'v1' as const; + +export type GrantDerivationVersion = typeof GRANT_DERIVATION_VERSION; + +/** The deterministic inputs to the HKDF. These + the user master key + * fully determine the MDK; any change produces a different key. */ +export interface GrantDerivation { + readonly version: GrantDerivationVersion; + readonly missionId: string; + /** Encrypted table names this grant covers, e.g. `['notes','tasks']`. */ + readonly tables: readonly string[]; + /** Allowlisted record IDs across the referenced tables. Format: + * `":"` so IDs are qualified and can't collide across + * tables (e.g. `"notes:abc"` vs `"tasks:abc"` are distinct). */ + readonly recordIds: readonly string[]; +} + +/** What gets stored on `Mission.grant`. `wrappedKey` is the RSA-OAEP + * output (base64) of the 32-byte MDK. Nothing sensitive here — but + * also nothing that the Mission owner shouldn't see. */ +export interface MissionGrant { + readonly wrappedKey: string; + readonly derivation: GrantDerivation; + /** ISO timestamp of when the grant was minted. */ + readonly issuedAt: string; + /** ISO timestamp after which the grant is no longer honoured. The + * server rejects missions with `expiresAt < now()` and surfaces a + * `grant-missing` state so the webapp can prompt for re-consent. */ + readonly expiresAt: string; +} + +/** + * Canonical HKDF-SHA256 derivation of the Mission Data Key. + * + * Both the webapp (Web Crypto in the browser) and mana-auth (Web Crypto + * in Bun) must produce byte-identical output for the same inputs, or + * the server cannot decrypt what the grant protects. The drift-guard + * test in `grant.test.ts` asserts this with a fixed master key. + * + * Returns a 32-byte AES-GCM-256 key as a non-extractable CryptoKey. + * Callers that need to wrap the raw bytes (the webapp, before RSA-OAEP) + * should use `deriveMissionDataKeyRaw` instead; callers that will only + * use the key for decryption (mana-ai after unwrap) should use this one. + */ +export async function deriveMissionDataKey( + masterKey: Uint8Array, + derivation: GrantDerivation +): Promise { + const bytes = await deriveMissionDataKeyRaw(masterKey, derivation); + try { + return await crypto.subtle.importKey( + 'raw', + toBufferSource(bytes), + { name: 'AES-GCM', length: 256 }, + /* extractable */ false, + ['decrypt'] + ); + } finally { + bytes.fill(0); + } +} + +/** Raw 32-byte form of the MDK. Caller is responsible for memzero-ing + * after use. Only the webapp's wrap path needs this; everyone else + * should prefer the CryptoKey variant. */ +export async function deriveMissionDataKeyRaw( + masterKey: Uint8Array, + derivation: GrantDerivation +): Promise { + if (masterKey.length !== 32) { + throw new Error(`shared-ai/grant: expected 32-byte master key, got ${masterKey.length}`); + } + if (derivation.version !== GRANT_DERIVATION_VERSION) { + throw new Error( + `shared-ai/grant: unsupported derivation version ${derivation.version}, expected ${GRANT_DERIVATION_VERSION}` + ); + } + + const ikm = await crypto.subtle.importKey( + 'raw', + toBufferSource(masterKey), + 'HKDF', + /* extractable */ false, + ['deriveBits'] + ); + + // Salt: missionId UTF-8 bytes. Deliberately NOT the master key — + // salt + IKM collapse in HKDF-Extract, but using the missionId + // gives every mission its own PRK space at extract time and keeps + // the info field free for the scope binding. + const salt = new TextEncoder().encode(derivation.missionId); + + const info = new TextEncoder().encode(canonicalInfoString(derivation)); + + const bits = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: toBufferSource(salt), + info: toBufferSource(info), + }, + ikm, + 256 // 32 bytes + ); + + return new Uint8Array(bits); +} + +/** + * Canonical serialisation of the scope into the HKDF info string. + * Sorted + joined to make the output order-independent: `[notes,tasks]` + * and `[tasks,notes]` derive the same key. Exported so tests can pin + * the exact string format. + */ +export function canonicalInfoString(derivation: GrantDerivation): string { + const tables = [...derivation.tables].sort().join(','); + const ids = [...derivation.recordIds].sort().join(','); + return `mana-ai-mission-grant:${derivation.version}:tables=${tables}:ids=${ids}`; +} + +// ─── Internals ──────────────────────────────────────────────── + +/** TS 5.7 compat — Uint8Array isn't assignable to + * BufferSource in every context. Copying into a fresh ArrayBuffer + * sidesteps the issue and matches what mana-auth/kek.ts already does. */ +function toBufferSource(bytes: Uint8Array): ArrayBuffer { + const buf = new ArrayBuffer(bytes.length); + new Uint8Array(buf).set(bytes); + return buf; +} diff --git a/packages/shared-ai/src/missions/index.ts b/packages/shared-ai/src/missions/index.ts index 3cb4bd29c..a4d7db526 100644 --- a/packages/shared-ai/src/missions/index.ts +++ b/packages/shared-ai/src/missions/index.ts @@ -6,3 +6,11 @@ export type { MissionState, PlanStep, } from './types'; + +export type { GrantDerivation, GrantDerivationVersion, MissionGrant } from './grant'; +export { + GRANT_DERIVATION_VERSION, + canonicalInfoString, + deriveMissionDataKey, + deriveMissionDataKeyRaw, +} from './grant'; diff --git a/packages/shared-ai/src/missions/types.ts b/packages/shared-ai/src/missions/types.ts index 8dbe77f1e..4f97f4ece 100644 --- a/packages/shared-ai/src/missions/types.ts +++ b/packages/shared-ai/src/missions/types.ts @@ -75,4 +75,12 @@ export interface Mission { iterations: readonly MissionIteration[]; userId?: string; deletedAt?: string; + /** + * Key-Grant for server-side execution on encrypted inputs. When set, + * `mana-ai` can decrypt the referenced records without the user's + * browser tab being open. Absent or expired → server-side Runner + * skips the mission (state='grant-missing'), foreground Runner is + * unaffected. See `./grant.ts` and `docs/plans/ai-mission-key-grant.md`. + */ + grant?: import('./grant').MissionGrant; }