diff --git a/services/mana-ai/CLAUDE.md b/services/mana-ai/CLAUDE.md index 47a41ab97..e7ea4b414 100644 --- a/services/mana-ai/CLAUDE.md +++ b/services/mana-ai/CLAUDE.md @@ -30,10 +30,35 @@ What works end-to-end: also blackbox-probed and surfaces on **status.mana.how** under "Internal" as "Mana AI Runner". -All roadmap items shipped. Future polish (not blockers): +All v0.3 roadmap items shipped. Future polish (not blockers): - Multi-instance deploy with advisory locks on snapshot refresh (today single-process) - Read-only `/internal/missions/:userId` endpoint for ops inspection +## Status: v0.4 (Mission Key-Grants, in Arbeit) + +Opt-in Mechanismus zum Entschluesseln der encrypted Input-Tabellen (notes, tasks, events, journal, kontext) serverseitig. Plan: [`docs/plans/ai-mission-key-grant.md`](../../docs/plans/ai-mission-key-grant.md). Architektur: [`docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md` §21](../../docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md). + +Was steht (Phase 0-2, Backend): + +- [x] RSA-OAEP-2048 keypair slots — `MANA_AI_PRIVATE_KEY_PEM` (ai) / `MANA_AI_PUBLIC_KEY_PEM` (auth). Ohne Env-Var laeuft der Service unveraendert; Grants werden dann einfach uebersprungen. +- [x] Canonical HKDF in `@mana/shared-ai` (`missions/grant.ts`). Scope-Binding (tables + recordIds) via `info`-String → Scope-Change = neuer Key = existierender Grant automatisch invalidiert. +- [x] `POST /api/v1/me/ai-mission-grant` auf mana-auth — leitet MDK ab, RSA-wrapped, lehnt Zero-Knowledge-User ab, TTL-clamped [1h, 30d]. +- [x] `mana_ai.decrypt_audit` Tabelle + RLS (`user_scope` via `app.current_user_id`). Append-only. +- [x] `crypto/unwrap-grant.ts` — Private-Key-Import, Grant-Entwrapping mit structured reasons (`not-configured` / `expired` / `wrap-rejected` / `malformed`). +- [x] `crypto/decrypt-value.ts` — Mirror des webapp AES-GCM wire format (`enc:1:.`). +- [x] Encrypted Resolver (`db/resolvers/encrypted.ts`) fuer notes / tasks / calendar / journal / kontext. Checkt recordId-Allowlist, replayt Record, entschluesselt `enc:1:`-Felder, schreibt Audit-Row pro Record. +- [x] Tick-Loop-Integration (`cron/tick.ts`) — unwrappt Grant pro Mission, baut `ResolverContext` mit `mdk + allowlist`, Key lebt nur waehrend `planOneMission`. +- [x] Metriken: `mana_ai_decrypts_total{table}`, `mana_ai_grant_scope_violations_total{table}` (Alert > 0!), `mana_ai_grant_skips_total{reason}`. + +Was offen ist (Phase 3, Frontend): + +- [ ] Webapp `MissionGrantDialog` + Consent-Flow im `/companion/missions`-Editor. +- [ ] Revoke-Button + "Mission → Datenzugriff" Audit-Tab in `/companion/workbench`. +- [ ] Scope-Change-UX: neue Records → Re-Consent-Prompt. +- [ ] `GET /internal/audit?missionId=` Endpoint (read-only) fuer die UI. +- [ ] Feature-Flag `PUBLIC_AI_MISSION_GRANTS=false` default + Rollout (till → beta → alpha). +- [ ] Produktions-Keypair generieren + in Mac-Mini Secrets ablegen. + ## Port: 3066 ## Tech Stack diff --git a/services/mana-ai/src/cron/tick.ts b/services/mana-ai/src/cron/tick.ts index e1d0b8d67..f52da2f4b 100644 --- a/services/mana-ai/src/cron/tick.ts +++ b/services/mana-ai/src/cron/tick.ts @@ -37,7 +37,10 @@ import { snapshotsNewTotal, snapshotsUpdatedTotal, snapshotRowsAppliedTotal, + grantSkipsTotal, } from '../metrics'; +import { unwrapMissionGrant } from '../crypto/unwrap-grant'; +import type { ResolverContext } from '../db/resolvers/types'; import type { Config } from '../config'; export interface TickStats { @@ -161,10 +164,15 @@ async function planOneMission( sql: Sql ): Promise { const mission = serverMissionToSharedMission(m); - // Resolvers skip silently for modules they don't handle (notes / kontext - // etc. are encrypted — server can't project them). The Planner then sees - // only plaintext-safe context (today: goals), plus concept + objective. - const resolvedInputs = await resolveServerInputs(sql, m.inputs, m.userId); + // Resolve the mission's Key-Grant (if any) once per tick. An absent + // grant is NOT an error — plaintext missions (goals-only) run fine + // without one; encrypted-input missions degrade to "null inputs" and + // the foreground runner takes over. A present-but-expired / -malformed + // grant bumps a metric and otherwise behaves the same. The MDK never + // leaves this function's scope; after planning finishes the CryptoKey + // reference goes out of scope and gets GC'd. + const context = await buildResolverContext(m); + const resolvedInputs = await resolveServerInputs(sql, m.inputs, m.userId, context); const input: AiPlanInput = { mission, resolvedInputs, @@ -183,6 +191,32 @@ async function planOneMission( return parsed.value; } +/** + * Build the per-mission ResolverContext. Extracted so the tick flow + * stays readable and so unit tests can drive it directly. + * + * For a mission without a grant, the context has no MDK and no + * allowlist — encrypted resolvers return null for their refs, plaintext + * resolvers run unchanged. For a mission WITH a grant, we try to unwrap + * and build an allowlist; failures bump a metric but never throw. + */ +async function buildResolverContext(m: ServerMission): Promise { + if (!m.grant) return { missionId: m.id }; + + const unwrap = await unwrapMissionGrant(m.grant); + if (!unwrap.ok) { + grantSkipsTotal.inc({ reason: unwrap.reason }); + console.warn(`[mana-ai tick] mission=${m.id} grant unwrap skipped: reason=${unwrap.reason}`); + return { missionId: m.id }; + } + + return { + missionId: m.id, + mdk: unwrap.mdk, + allowlist: new Set(m.grant.derivation.recordIds), + }; +} + /** * Projection → shared-ai Mission shape. The projection leaves a few * fields as `unknown` because the server doesn't need to interpret them diff --git a/services/mana-ai/src/crypto/decrypt-value.test.ts b/services/mana-ai/src/crypto/decrypt-value.test.ts new file mode 100644 index 000000000..9283ced04 --- /dev/null +++ b/services/mana-ai/src/crypto/decrypt-value.test.ts @@ -0,0 +1,127 @@ +/** + * Round-trip test between the webapp's wrap format and mana-ai's unwrap. + * + * The webapp's wire format is documented in + * `apps/mana/apps/web/src/lib/data/crypto/aes.ts`. We re-implement the + * wrap side here (server-side we never wrap, but the test needs to + * produce the wire format somehow) and assert the unwrap result matches + * the original value. + */ + +import { describe, it, expect } from 'bun:test'; +import { isEncrypted, unwrapValue, decryptRecordFields } from './decrypt-value'; + +const ENC_PREFIX = 'enc:1:'; + +async function webappWrap(value: unknown, key: CryptoKey): Promise { + if (value === null || value === undefined) return value; + const json = JSON.stringify(value); + const plaintext = new TextEncoder().encode(json); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: toBufferSource(iv) }, + key, + toBufferSource(plaintext) + ); + return ENC_PREFIX + bytesToBase64(iv) + '.' + bytesToBase64(new Uint8Array(ct)); +} + +async function freshKey(): Promise { + return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); +} + +describe('isEncrypted', () => { + it('recognises the prefix', () => { + expect(isEncrypted('enc:1:abc.def')).toBe(true); + expect(isEncrypted('plain string')).toBe(false); + expect(isEncrypted(null)).toBe(false); + expect(isEncrypted(undefined)).toBe(false); + expect(isEncrypted(42)).toBe(false); + }); +}); + +describe('unwrapValue', () => { + it('round-trips strings', async () => { + const key = await freshKey(); + const blob = await webappWrap('hello world', key); + expect(await unwrapValue(blob, key)).toBe('hello world'); + }); + + it('round-trips arrays and objects (same JSON envelope as webapp)', async () => { + const key = await freshKey(); + const arr = ['a', 1, { nested: true }]; + const blobArr = await webappWrap(arr, key); + expect(await unwrapValue(blobArr, key)).toEqual(arr); + + const obj = { title: 'x', tags: ['y', 'z'] }; + const blobObj = await webappWrap(obj, key); + expect(await unwrapValue(blobObj, key)).toEqual(obj); + }); + + it('passes through null, undefined, non-strings', async () => { + const key = await freshKey(); + expect(await unwrapValue(null, key)).toBe(null); + expect(await unwrapValue(undefined, key)).toBe(undefined); + expect(await unwrapValue(42, key)).toBe(42); + expect(await unwrapValue('plain', key)).toBe('plain'); + }); + + it('throws on tampered ciphertext', async () => { + const key = await freshKey(); + const blob = (await webappWrap('secret', key)) as string; + // Flip a byte in the ciphertext part (after the dot). + const dot = blob.indexOf('.'); + const tampered = blob.slice(0, dot + 1) + 'A' + blob.slice(dot + 2); + await expect(unwrapValue(tampered, key)).rejects.toThrow(); + }); + + it('throws on malformed prefix', async () => { + const key = await freshKey(); + await expect(unwrapValue('enc:1:nodot', key)).rejects.toThrow(/malformed/); + }); + + it('throws on wrong key', async () => { + const k1 = await freshKey(); + const k2 = await freshKey(); + const blob = await webappWrap('secret', k1); + await expect(unwrapValue(blob, k2)).rejects.toThrow(); + }); +}); + +describe('decryptRecordFields', () => { + it('decrypts only enc: fields and reports which', async () => { + const key = await freshKey(); + const record = { + id: 'r1', + title: await webappWrap('Private Title', key), + content: await webappWrap({ markdown: 'secret' }, key), + createdAt: '2026-04-15', + userId: 'u1', + }; + const { record: out, decryptedFields } = await decryptRecordFields(record, key); + expect(out.title).toBe('Private Title'); + expect(out.content).toEqual({ markdown: 'secret' }); + expect(out.id).toBe('r1'); + expect(out.createdAt).toBe('2026-04-15'); + expect(decryptedFields.sort()).toEqual(['content', 'title']); + }); + + it('is a no-op for records with no encrypted fields', async () => { + const key = await freshKey(); + const record = { id: 'r1', value: 42, period: 'week' }; + const { decryptedFields } = await decryptRecordFields(record, key); + expect(decryptedFields).toEqual([]); + }); +}); + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} + +function toBufferSource(bytes: Uint8Array): ArrayBuffer { + const buf = new ArrayBuffer(bytes.length); + new Uint8Array(buf).set(bytes); + return buf; +} diff --git a/services/mana-ai/src/crypto/decrypt-value.ts b/services/mana-ai/src/crypto/decrypt-value.ts new file mode 100644 index 000000000..c4e825f39 --- /dev/null +++ b/services/mana-ai/src/crypto/decrypt-value.ts @@ -0,0 +1,95 @@ +/** + * AES-GCM unwrap — server-side mirror of the webapp's `aes.ts#unwrapValue`. + * + * Same wire format: + * `enc:1:${base64(iv)}.${base64(ct)}` + * + * The webapp's pipeline JSON-stringifies every value before encryption, so + * we JSON.parse after decryption and the type round-trips. Non-prefixed + * values pass through unchanged, matching the webapp's "safe to apply + * unconditionally" semantics. + * + * Throws on tampered ciphertext (AES-GCM auth tag mismatch), malformed + * blobs, wrong key. The resolver catches and converts into audit rows. + * + * The companion `wrapValue` is intentionally NOT implemented here — + * mana-ai never writes encrypted fields; it only reads them. Keeping the + * surface read-only makes it obvious in review that this service cannot + * corrupt user data cryptographically. + */ + +const ENC_PREFIX = 'enc:1:'; +const IV_LENGTH = 12; + +export function isEncrypted(value: unknown): boolean { + return typeof value === 'string' && value.startsWith(ENC_PREFIX); +} + +/** + * Decrypts a wire-format blob back to its original JSON value. For non- + * encrypted inputs (null, non-strings, strings without the prefix) it + * returns the value as-is. That way a resolver can apply it over every + * field of a mixed-encryption record without a per-field check. + */ +export async function unwrapValue(blob: unknown, key: CryptoKey): Promise { + if (!isEncrypted(blob)) return blob; + + const body = (blob as string).slice(ENC_PREFIX.length); + const dotIndex = body.indexOf('.'); + if (dotIndex === -1) { + throw new Error('mana-ai/crypto: malformed encrypted blob (missing iv/ct separator)'); + } + + const iv = base64ToBytes(body.slice(0, dotIndex)); + const ct = base64ToBytes(body.slice(dotIndex + 1)); + + if (iv.length !== IV_LENGTH) { + throw new Error(`mana-ai/crypto: expected ${IV_LENGTH}-byte IV, got ${iv.length}`); + } + + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: toBufferSource(iv) }, + key, + toBufferSource(ct) + ); + + return JSON.parse(new TextDecoder().decode(plaintext)); +} + +/** + * Decrypts every enc:1:-prefixed string field on a record in place. Non- + * prefixed fields are left alone. Returns the same record reference for + * caller convenience. + * + * Counts how many fields were decrypted so the caller can decide whether + * to write an audit row (zero-decrypt records don't need one — no secret + * was touched). Failures bubble up; the caller is responsible for the + * try/catch and audit bookkeeping. + */ +export async function decryptRecordFields( + record: Record, + key: CryptoKey +): Promise<{ record: Record; decryptedFields: string[] }> { + const decryptedFields: string[] = []; + for (const [k, v] of Object.entries(record)) { + if (!isEncrypted(v)) continue; + record[k] = await unwrapValue(v, key); + decryptedFields.push(k); + } + return { record, decryptedFields }; +} + +// ─── Helpers ───────────────────────────────────────────────── + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function toBufferSource(bytes: Uint8Array): ArrayBuffer { + const buf = new ArrayBuffer(bytes.length); + new Uint8Array(buf).set(bytes); + return buf; +} diff --git a/services/mana-ai/src/db/audit.ts b/services/mana-ai/src/db/audit.ts new file mode 100644 index 000000000..da07f5b0a --- /dev/null +++ b/services/mana-ai/src/db/audit.ts @@ -0,0 +1,69 @@ +/** + * Decrypt-audit writer — appends one row per server-side decrypt attempt + * to `mana_ai.decrypt_audit`. + * + * Invariants: + * - **Append-only.** Never mutate or delete. Expire old rows via a + * separate retention job if at all. + * - **Write before decrypt**, not after. If the decrypt itself throws, + * we still want the attempt on record so forensics can tell + * "mana-ai tried to read record X". The writer is called with the + * *outcome* though — pattern is: try decrypt; in success/failure + * branch, call `writeDecryptAudit` with the right status. On an + * unexpected throw (network, DB), the audit row is lost — acceptable + * because the decrypt didn't complete either. + * - **Best-effort.** A write failure is logged but never escalates; we + * must not break Mission ticks because the audit table is unhappy. + * + * Row volume: with ~50 active Missions × 5 inputs × 1 tick/minute, worst + * case is ~360k rows/day. Fine for Postgres but suggests a retention job + * at ~90 days. + */ + +import type { Sql } from './connection'; +import { withUser } from './connection'; + +export type DecryptAuditStatus = 'ok' | 'failed' | 'scope-violation'; + +export interface DecryptAuditEntry { + missionId: string; + iterationId?: string; + tableName: string; + recordId: string; + status: DecryptAuditStatus; + /** Short machine-readable reason on non-ok rows: `wrap-rejected`, + * `ciphertext-tampered`, `scope-record-not-allowlisted`, etc. */ + reason?: string; +} + +export async function writeDecryptAudit( + sql: Sql, + userId: string, + entry: DecryptAuditEntry +): Promise { + try { + await withUser(sql, userId, async (tx) => { + await tx` + INSERT INTO mana_ai.decrypt_audit ( + user_id, mission_id, iteration_id, table_name, record_id, status, reason + ) VALUES ( + ${userId}, + ${entry.missionId}, + ${entry.iterationId ?? null}, + ${entry.tableName}, + ${entry.recordId}, + ${entry.status}, + ${entry.reason ?? null} + ) + `; + }); + } catch (err) { + // Audit failures must never cascade into tick failures. Log loud + // enough that an operator notices and can investigate. + console.error( + '[mana-ai audit] failed to write decrypt_audit row:', + err instanceof Error ? err.message : String(err), + entry + ); + } +} diff --git a/services/mana-ai/src/db/missions-projection.ts b/services/mana-ai/src/db/missions-projection.ts index 4182a6a2a..0b948ef3b 100644 --- a/services/mana-ai/src/db/missions-projection.ts +++ b/services/mana-ai/src/db/missions-projection.ts @@ -10,6 +10,7 @@ * its deadline. */ +import type { MissionGrant } from '@mana/shared-ai'; import type { Sql } from './connection'; /** @@ -27,6 +28,9 @@ export interface ServerMission { inputs: { module: string; table: string; id: string }[]; cadence: unknown; // opaque — the browser Runner owns cadence math iterations: unknown[]; // opaque — server just reads count + /** Present iff the mission has a Key-Grant attached — enables + * decryption of encrypted-table inputs during this tick. */ + grant?: MissionGrant; } interface ChangeRow { @@ -75,6 +79,7 @@ export async function listDueMissions(sql: Sql, now: string): Promise { + return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); +} + +/** Webapp-side wrap; we emulate so tests can produce ciphertext the + * resolver then unwraps. */ +async function wrap(value: unknown, key: CryptoKey): Promise { + const json = JSON.stringify(value); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv.buffer.slice(0) as ArrayBuffer }, + key, + new TextEncoder().encode(json) + ); + return ENC_PREFIX + b64(iv) + '.' + b64(new Uint8Array(ct)); +} + +interface Capture { + queries: Array<{ text: string; values: unknown[] }>; + rows: Record; +} + +/** Build a stub Sql tag. Matches the calls made by replayRecord + audit + * writer. Responses are keyed by whether the query is SELECT (pulls + * from capture.rows) or INSERT/SET (no-op + captured for assertions). + * Template literal quirks: postgres.js calls the tag with + * (strings: string[], ...values: unknown[]). */ +function stubSql(capture: Capture): Sql { + const tag = ((strings: TemplateStringsArray, ...values: unknown[]) => { + const text = strings.join('?'); + capture.queries.push({ text, values }); + const promise: Promise = Promise.resolve( + text.includes('FROM sync_changes') ? (capture.rows.replay ?? []) : [] + ); + // postgres.js query objects also support .begin etc.; we only + // need the promise interface for these tests. + return promise; + }) as unknown as Sql; + // begin = transactional callback; we inline-run it against the same + // tag so the same query log is captured. + (tag as unknown as { begin: (fn: (tx: Sql) => Promise) => Promise }).begin = + async (fn) => fn(tag); + return tag; +} + +beforeEach(() => { + // nothing global; fresh capture per test +}); + +describe('encrypted resolver', () => { + it('returns null when the mission has no grant (no mdk in context)', async () => { + const resolver = createEncryptedResolver({ + module: 'notes', + appId: 'notes', + label: 'Notiz', + formatContent: (r) => String(r.content ?? ''), + }); + const capture: Capture = { queries: [], rows: {} }; + const out = await resolver( + stubSql(capture), + { module: 'notes', table: 'notes', id: 'n1' }, + 'user-1', + { missionId: 'm' } // no mdk, no allowlist + ); + expect(out).toBe(null); + // No DB work at all — we bailed before replay. + expect(capture.queries).toEqual([]); + }); + + it('writes scope-violation audit when record is not on the allowlist', async () => { + const key = await freshKey(); + const resolver = createEncryptedResolver({ + module: 'notes', + appId: 'notes', + label: 'Notiz', + formatContent: (r) => String(r.content ?? ''), + }); + const capture: Capture = { queries: [], rows: {} }; + const out = await resolver( + stubSql(capture), + { module: 'notes', table: 'notes', id: 'n-other' }, + 'user-1', + { missionId: 'm1', mdk: key, allowlist: new Set(['notes:n1']) } + ); + expect(out).toBe(null); + // Audit insert recorded. + const auditInsert = capture.queries.find((q) => + q.text.includes('INSERT INTO mana_ai.decrypt_audit') + ); + expect(auditInsert).toBeDefined(); + expect(auditInsert!.values).toContain('scope-violation'); + expect(auditInsert!.values).toContain('record-not-in-grant-allowlist'); + }); + + it('decrypts allowlisted records and writes ok audit', async () => { + const key = await freshKey(); + const encTitle = await wrap('Private Titel', key); + const encContent = await wrap('geheimer inhalt', key); + const capture: Capture = { + queries: [], + rows: { + replay: [ + { + op: 'insert', + data: { title: encTitle, content: encContent, createdAt: '2026-04-15' }, + field_timestamps: null, + created_at: new Date(0), + }, + ], + }, + }; + const resolver = createEncryptedResolver({ + module: 'notes', + appId: 'notes', + label: 'Notiz', + formatContent: (r) => String(r.content ?? ''), + }); + + const out = await resolver( + stubSql(capture), + { module: 'notes', table: 'notes', id: 'n1' }, + 'user-1', + { missionId: 'm1', mdk: key, allowlist: new Set(['notes:n1']) } + ); + + expect(out).not.toBe(null); + expect(out!.title).toBe('Private Titel'); + expect(out!.content).toBe('geheimer inhalt'); + + const audit = capture.queries.find((q) => q.text.includes('INSERT INTO mana_ai.decrypt_audit')); + expect(audit!.values).toContain('ok'); + }); + + it('writes failed audit when decrypt throws (wrong key)', async () => { + const wrapperKey = await freshKey(); + const runnerKey = await freshKey(); // different → ciphertext won't decrypt + const enc = await wrap('x', wrapperKey); + const capture: Capture = { + queries: [], + rows: { + replay: [ + { + op: 'insert', + data: { title: enc }, + field_timestamps: null, + created_at: new Date(0), + }, + ], + }, + }; + const resolver = createEncryptedResolver({ + module: 'notes', + appId: 'notes', + label: 'Notiz', + formatContent: (r) => String(r.title ?? ''), + }); + const out = await resolver( + stubSql(capture), + { module: 'notes', table: 'notes', id: 'n1' }, + 'user-1', + { missionId: 'm1', mdk: runnerKey, allowlist: new Set(['notes:n1']) } + ); + expect(out).toBe(null); + const audit = capture.queries.find((q) => q.text.includes('INSERT INTO mana_ai.decrypt_audit')); + expect(audit!.values).toContain('failed'); + }); +}); + +function b64(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} diff --git a/services/mana-ai/src/db/resolvers/encrypted.ts b/services/mana-ai/src/db/resolvers/encrypted.ts new file mode 100644 index 000000000..a7d0405fe --- /dev/null +++ b/services/mana-ai/src/db/resolvers/encrypted.ts @@ -0,0 +1,190 @@ +/** + * Encrypted-table resolver factory. + * + * One resolver per encrypted module (notes, tasks, events, journal, + * kontext, …). The factory parametrises the module/table name, the + * mini-formatter for the Planner's context string, and the set of + * encrypted fields we try to expose post-decrypt. + * + * Flow per resolve: + * 1. Check context has an MDK + allowlist (mission has a valid grant). + * Missing → return null; the tick loop already flagged the mission + * as grant-missing and the planner runs without this input. + * 2. Check `${table}:${recordId}` is on the allowlist. Record not + * allowlisted → write `scope-violation` audit row, bump metric, + * return null. This is belt+braces: the scope-bound HKDF would + * have produced a different key anyway, so decrypt would fail + * cryptographically. But we catch it earlier with a clear signal. + * 3. Replay the record with LWW. Record missing / deleted → null. + * 4. Decrypt all `enc:1:`-prefixed fields in place with the MDK. + * Crypto failure → `failed` audit row, return null. + * 5. Write `ok` audit row, format the Planner context, return. + */ + +import type { MissionInputRef, ResolvedInput } from '@mana/shared-ai'; +import { decryptRecordFields } from '../../crypto/decrypt-value'; +import { writeDecryptAudit } from '../audit'; +import { replayRecord } from './record-replay'; +import type { ResolverContext, ServerInputResolver } from './types'; +import { decryptsTotal, grantScopeViolationsTotal } from '../../metrics'; + +export interface EncryptedResolverConfig { + /** Module name registered with the resolver registry. */ + readonly module: string; + /** Dexie/app_id where the records live in `sync_changes` (usually + * the same as `module`, but decoupled because some modules sync + * under a different app id — e.g. `aiMissions` lives under `ai`). */ + readonly appId: string; + /** Human label used in the Planner's context title. */ + readonly label: string; + /** Extracts a short content string from the (decrypted) record for + * the Planner. Typical impl: pluck `title`/`content`, truncate. */ + readonly formatContent: (record: Record) => string; + /** Extracts a title line for the Planner; falls back to the record id + * if unset. */ + readonly formatTitle?: (record: Record) => string; +} + +export function createEncryptedResolver(cfg: EncryptedResolverConfig): ServerInputResolver { + return async function encryptedResolver(sql, ref, userId, ctx) { + if (!ctx.mdk || !ctx.allowlist) { + // No grant → silently yield null. The Planner runs without + // this input; the foreground runner picks up the slack when + // the user next opens a tab. No audit row: no decrypt attempt. + return null; + } + + const scopeKey = `${ref.table}:${ref.id}`; + if (!ctx.allowlist.has(scopeKey)) { + grantScopeViolationsTotal.inc({ table: ref.table }); + await writeDecryptAudit(sql, userId, { + missionId: ctx.missionId, + tableName: ref.table, + recordId: ref.id, + status: 'scope-violation', + reason: 'record-not-in-grant-allowlist', + }); + return null; + } + + const record = (await replayRecord(sql, userId, cfg.appId, ref.table, ref.id)) as Record< + string, + unknown + > | null; + if (!record) return null; + + try { + const { decryptedFields } = await decryptRecordFields(record, ctx.mdk); + if (decryptedFields.length > 0) { + decryptsTotal.inc({ table: ref.table }, decryptedFields.length); + } + } catch (err) { + await writeDecryptAudit(sql, userId, { + missionId: ctx.missionId, + tableName: ref.table, + recordId: ref.id, + status: 'failed', + reason: errorReason(err), + }); + return null; + } + + await writeDecryptAudit(sql, userId, { + missionId: ctx.missionId, + tableName: ref.table, + recordId: ref.id, + status: 'ok', + }); + + return toResolvedInput(cfg, ref, record); + }; +} + +function toResolvedInput( + cfg: EncryptedResolverConfig, + ref: MissionInputRef, + record: Record +): ResolvedInput { + const title = + (cfg.formatTitle ? cfg.formatTitle(record) : undefined) || + (typeof record.title === 'string' ? record.title : cfg.label); + return { + id: ref.id, + module: ref.module, + table: ref.table, + title, + content: cfg.formatContent(record), + }; +} + +function errorReason(err: unknown): string { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('malformed')) return 'ciphertext-malformed'; + // AES-GCM auth-tag failures surface as DOMException/OperationError. + // Return a stable short string rather than the DOM message (which + // varies across Bun versions). + if (/OperationError|decrypt/i.test(msg)) return 'ciphertext-tampered-or-wrong-key'; + return 'decrypt-failed'; +} + +// ─── Built-in configs for the five encrypted modules ──────── + +/** Truncate long text content so the Planner prompt stays tight. */ +function truncate(s: string, max = 500): string { + if (s.length <= max) return s; + return s.slice(0, max) + '…'; +} + +export const notesResolver = createEncryptedResolver({ + module: 'notes', + appId: 'notes', + label: 'Notiz', + formatContent: (r) => + truncate(typeof r.content === 'string' ? r.content : JSON.stringify(r.content ?? '')), +}); + +export const tasksResolver = createEncryptedResolver({ + module: 'tasks', + appId: 'todo', + label: 'Task', + formatContent: (r) => { + const title = typeof r.title === 'string' ? r.title : '(ohne Titel)'; + const desc = typeof r.description === 'string' ? r.description : ''; + const status = typeof r.status === 'string' ? r.status : ''; + const due = typeof r.dueDate === 'string' ? r.dueDate : ''; + const parts = [status && `[${status}]`, title, desc && `— ${desc}`, due && `(faellig: ${due})`] + .filter(Boolean) + .join(' '); + return truncate(parts); + }, +}); + +export const eventsResolver = createEncryptedResolver({ + module: 'calendar', + appId: 'calendar', + label: 'Termin', + formatContent: (r) => { + const title = typeof r.title === 'string' ? r.title : '(ohne Titel)'; + const start = typeof r.startDate === 'string' ? r.startDate : ''; + const location = typeof r.location === 'string' ? r.location : ''; + return truncate( + [title, start && `@ ${start}`, location && `in ${location}`].filter(Boolean).join(' ') + ); + }, +}); + +export const journalResolver = createEncryptedResolver({ + module: 'journal', + appId: 'journal', + label: 'Journal-Eintrag', + formatContent: (r) => truncate(typeof r.content === 'string' ? r.content : ''), +}); + +export const kontextResolver = createEncryptedResolver({ + module: 'kontext', + appId: 'kontext', + label: 'Kontext', + formatTitle: () => 'Mana-Kontext', + formatContent: (r) => + truncate(typeof r.content === 'string' ? r.content : JSON.stringify(r.content ?? ''), 1500), +}); diff --git a/services/mana-ai/src/db/resolvers/goals.ts b/services/mana-ai/src/db/resolvers/goals.ts index eef6f9c63..0087c4dfa 100644 --- a/services/mana-ai/src/db/resolvers/goals.ts +++ b/services/mana-ai/src/db/resolvers/goals.ts @@ -19,7 +19,7 @@ interface GoalRecord { deletedAt?: string; } -export const goalsResolver: ServerInputResolver = async (sql, ref, userId) => { +export const goalsResolver: ServerInputResolver = async (sql, ref, userId, _context) => { const record = (await replayRecord( sql, userId, diff --git a/services/mana-ai/src/db/resolvers/index.ts b/services/mana-ai/src/db/resolvers/index.ts index 62e3d767e..802ead0c2 100644 --- a/services/mana-ai/src/db/resolvers/index.ts +++ b/services/mana-ai/src/db/resolvers/index.ts @@ -9,8 +9,15 @@ import type { Sql } from '../connection'; import type { MissionInputRef, ResolvedInput } from '@mana/shared-ai'; -import type { ServerInputResolver } from './types'; +import type { ResolverContext, ServerInputResolver } from './types'; import { goalsResolver } from './goals'; +import { + eventsResolver, + journalResolver, + kontextResolver, + notesResolver, + tasksResolver, +} from './encrypted'; const resolvers = new Map(); @@ -22,24 +29,34 @@ export function unregisterServerResolver(moduleName: string): void { resolvers.delete(moduleName); } -// Seed with the built-in plaintext resolvers. Encrypted modules (notes, -// kontext, journal, dreams, …) are intentionally NOT registered — the -// server only sees ciphertext for those tables and can't produce useful -// Planner context. Missions referencing them should use the foreground -// runner; see CLAUDE.md → "Privacy constraint" for rationale. +// Plaintext resolvers run for every mission — no grant needed. registerServerResolver('goals', goalsResolver); +// Encrypted resolvers require a currently-valid Mission Grant. Without +// one they return null per ref; the Planner then runs with fewer inputs +// and the foreground runner picks up the slack on the user's next +// browser visit. Encryption registry source: `apps/mana/apps/web/src/ +// lib/data/crypto/registry.ts` — keep the table set here in sync with +// the set flipped to `enabled: true` there. +registerServerResolver('notes', notesResolver); +registerServerResolver('tasks', tasksResolver); +registerServerResolver('todo', tasksResolver); +registerServerResolver('calendar', eventsResolver); +registerServerResolver('journal', journalResolver); +registerServerResolver('kontext', kontextResolver); + export async function resolveServerInputs( sql: Sql, refs: readonly MissionInputRef[], - userId: string + userId: string, + context: ResolverContext ): Promise { const results = await Promise.all( refs.map(async (ref) => { const resolver = resolvers.get(ref.module); if (!resolver) return null; try { - return await resolver(sql, ref, userId); + return await resolver(sql, ref, userId, context); } catch (err) { console.error( `[mana-ai resolver] module=${ref.module} ref=${ref.id} threw:`, @@ -52,4 +69,4 @@ export async function resolveServerInputs( return results.filter((r): r is ResolvedInput => r !== null); } -export type { ServerInputResolver } from './types'; +export type { ResolverContext, ServerInputResolver } from './types'; diff --git a/services/mana-ai/src/db/resolvers/resolvers.test.ts b/services/mana-ai/src/db/resolvers/resolvers.test.ts index ac92e868b..397440afa 100644 --- a/services/mana-ai/src/db/resolvers/resolvers.test.ts +++ b/services/mana-ai/src/db/resolvers/resolvers.test.ts @@ -22,14 +22,14 @@ describe('resolveServerInputs', () => { })); const refs: MissionInputRef[] = [{ module: 'resolver_test_mod', table: 't', id: 'a' }]; - const resolved = await resolveServerInputs(stubSql, refs, 'user-1'); + const resolved = await resolveServerInputs(stubSql, refs, 'user-1', { missionId: 'm' }); expect(resolved).toHaveLength(1); expect(resolved[0].content).toBe('content for a'); }); it('skips refs whose module has no registered resolver', async () => { const refs: MissionInputRef[] = [{ module: 'does-not-exist', table: 't', id: 'x' }]; - const resolved = await resolveServerInputs(stubSql, refs, 'u'); + const resolved = await resolveServerInputs(stubSql, refs, 'u', { missionId: 'm' }); expect(resolved).toEqual([]); }); @@ -38,7 +38,7 @@ describe('resolveServerInputs', () => { throw new Error('broken'); }); const refs: MissionInputRef[] = [{ module: 'resolver_test_boom', table: 't', id: 'x' }]; - const resolved = await resolveServerInputs(stubSql, refs, 'u'); + const resolved = await resolveServerInputs(stubSql, refs, 'u', { missionId: 'm' }); expect(resolved).toEqual([]); }); @@ -54,7 +54,7 @@ describe('resolveServerInputs', () => { { module: 'unknown', table: 't', id: 'b' }, { module: 'resolver_test_mod', table: 't', id: 'c' }, ]; - const resolved = await resolveServerInputs(stubSql, refs, 'u'); + const resolved = await resolveServerInputs(stubSql, refs, 'u', { missionId: 'm' }); expect(resolved).toHaveLength(2); }); @@ -63,7 +63,7 @@ describe('resolveServerInputs', () => { // checking the empty-refs path doesn't throw and that an unknown // module still skips — any negative-space test, since we can't // invoke the goals resolver without a live DB here. - const resolved = await resolveServerInputs(stubSql, [], 'u'); + const resolved = await resolveServerInputs(stubSql, [], 'u', { missionId: 'm' }); expect(resolved).toEqual([]); }); }); diff --git a/services/mana-ai/src/db/resolvers/types.ts b/services/mana-ai/src/db/resolvers/types.ts index a59f307de..06b7dfb45 100644 --- a/services/mana-ai/src/db/resolvers/types.ts +++ b/services/mana-ai/src/db/resolvers/types.ts @@ -21,8 +21,29 @@ import type { Sql } from '../connection'; import type { MissionInputRef, ResolvedInput } from '@mana/shared-ai'; +/** + * Per-tick context passed into every resolver. Lets encrypted resolvers + * read the Mission Data Key (MDK) + allowlist without the registry + * having to know which resolvers need it. Plaintext resolvers ignore + * these fields entirely. + */ +export interface ResolverContext { + /** Mission whose input is being resolved — used for audit attribution. */ + missionId: string; + /** Unwrapped AES-GCM Mission Data Key, if the mission carries a + * currently-valid grant. Undefined for missions without a grant or + * when the unwrap failed; plaintext resolvers don't care. */ + mdk?: CryptoKey; + /** Record-ID allowlist from the grant, in `${table}:${recordId}` form. + * Encrypted resolvers MUST refuse to decrypt anything outside this + * set (cryptographically enforced by the scope-bound HKDF, but + * double-checked at runtime to produce clean audit trails). */ + allowlist?: ReadonlySet; +} + export type ServerInputResolver = ( sql: Sql, ref: MissionInputRef, - userId: string + userId: string, + context: ResolverContext ) => Promise; diff --git a/services/mana-ai/src/metrics.ts b/services/mana-ai/src/metrics.ts index 498ae0907..de07cb5dc 100644 --- a/services/mana-ai/src/metrics.ts +++ b/services/mana-ai/src/metrics.ts @@ -98,3 +98,28 @@ export const snapshotRowsAppliedTotal = new Counter({ help: 'Sync-changes rows folded into the snapshot cache.', registers: [register], }); + +// ── Mission Key-Grant (Phase 2+) ────────────────────────── + +export const decryptsTotal = new Counter({ + name: 'mana_ai_decrypts_total', + help: 'Server-side field decrypts performed under a Mission grant.', + labelNames: ['table'] as const, + registers: [register], +}); + +/** Must remain at 0 in steady state — any increment indicates a record + * was requested outside the grant's allowlist. Alert on > 0. */ +export const grantScopeViolationsTotal = new Counter({ + name: 'mana_ai_grant_scope_violations_total', + help: 'Decrypt attempts rejected because the record was not on the grant allowlist.', + labelNames: ['table'] as const, + registers: [register], +}); + +export const grantSkipsTotal = new Counter({ + name: 'mana_ai_grant_skips_total', + help: 'Missions skipped because their grant was missing, expired, or unwrappable.', + labelNames: ['reason'] as const, + registers: [register], +});